diff --git a/app/electron/services/claude-cli-detector.js b/app/electron/services/claude-cli-detector.js index 7f112001..715754db 100644 --- a/app/electron/services/claude-cli-detector.js +++ b/app/electron/services/claude-cli-detector.js @@ -1,7 +1,7 @@ -const { execSync, spawn } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const { execSync, spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); /** * Claude CLI Detector @@ -21,41 +21,47 @@ class ClaudeCliDetector { */ static getUpdatedPathFromShellConfig() { const homeDir = os.homedir(); - const shell = process.env.SHELL || '/bin/bash'; + const shell = process.env.SHELL || "/bin/bash"; const shellName = path.basename(shell); - + // Common shell config files const configFiles = []; - if (shellName.includes('zsh')) { - configFiles.push(path.join(homeDir, '.zshrc')); - configFiles.push(path.join(homeDir, '.zshenv')); - configFiles.push(path.join(homeDir, '.zprofile')); - } else if (shellName.includes('bash')) { - configFiles.push(path.join(homeDir, '.bashrc')); - configFiles.push(path.join(homeDir, '.bash_profile')); - configFiles.push(path.join(homeDir, '.profile')); + if (shellName.includes("zsh")) { + configFiles.push(path.join(homeDir, ".zshrc")); + configFiles.push(path.join(homeDir, ".zshenv")); + configFiles.push(path.join(homeDir, ".zprofile")); + } else if (shellName.includes("bash")) { + configFiles.push(path.join(homeDir, ".bashrc")); + configFiles.push(path.join(homeDir, ".bash_profile")); + configFiles.push(path.join(homeDir, ".profile")); } - + // Also check common locations const commonPaths = [ - path.join(homeDir, '.local', 'bin'), - path.join(homeDir, '.cargo', 'bin'), - '/usr/local/bin', - '/opt/homebrew/bin', - path.join(homeDir, 'bin'), + path.join(homeDir, ".local", "bin"), + path.join(homeDir, ".cargo", "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(homeDir, "bin"), ]; - + // Try to extract PATH additions from config files for (const configFile of configFiles) { if (fs.existsSync(configFile)) { try { - const content = fs.readFileSync(configFile, 'utf-8'); + const content = fs.readFileSync(configFile, "utf-8"); // Look for PATH exports that might include claude installation paths - const pathMatches = content.match(/export\s+PATH=["']?([^"'\n]+)["']?/g); + const pathMatches = content.match( + /export\s+PATH=["']?([^"'\n]+)["']?/g + ); if (pathMatches) { for (const match of pathMatches) { - const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, ''); - const paths = pathValue.split(':').filter(p => p && !p.includes('$')); + const pathValue = match + .replace(/export\s+PATH=["']?/, "") + .replace(/["']?$/, ""); + const paths = pathValue + .split(":") + .filter((p) => p && !p.includes("$")); commonPaths.push(...paths); } } @@ -64,26 +70,33 @@ class ClaudeCliDetector { } } } - + return [...new Set(commonPaths)]; // Remove duplicates } static detectClaudeInstallation() { - console.log('[ClaudeCliDetector] Detecting Claude installation...'); + console.log("[ClaudeCliDetector] Detecting Claude installation..."); try { // Method 1: Check if 'claude' command is in PATH (Unix) - if (process.platform !== 'win32') { + if (process.platform !== "win32") { try { - const claudePath = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim(); + const claudePath = execSync("which claude 2>/dev/null", { + encoding: "utf-8", + }).trim(); if (claudePath) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } catch (error) { @@ -92,17 +105,26 @@ class ClaudeCliDetector { } // Method 2: Check Windows path - if (process.platform === 'win32') { + if (process.platform === "win32") { try { - const claudePath = execSync('where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0]; + const claudePath = execSync("where claude 2>nul", { + encoding: "utf-8", + }) + .trim() + .split("\n")[0]; if (claudePath) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } catch (error) { @@ -111,34 +133,49 @@ class ClaudeCliDetector { } // Method 3: Check for local installation - const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude'); + const localClaudePath = path.join( + os.homedir(), + ".claude", + "local", + "claude" + ); if (fs.existsSync(localClaudePath)) { const version = this.getClaudeVersion(localClaudePath); - console.log('[ClaudeCliDetector] Found local claude at:', localClaudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found local claude at:", + localClaudePath, + "version:", + version + ); return { installed: true, path: localClaudePath, version: version, - method: 'cli-local' + method: "cli-local", }; } // Method 4: Check common installation locations (including those from shell config) const commonPaths = this.getUpdatedPathFromShellConfig(); - const binaryNames = ['claude', 'claude-code']; - + const binaryNames = ["claude", "claude-code"]; + for (const basePath of commonPaths) { for (const binaryName of binaryNames) { const claudePath = path.join(basePath, binaryName); if (fs.existsSync(claudePath)) { try { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } catch (error) { // File exists but can't get version, might not be executable @@ -148,29 +185,37 @@ class ClaudeCliDetector { } // Method 5: Try to source shell config and check PATH again (for Unix) - if (process.platform !== 'win32') { + if (process.platform !== "win32") { try { - const shell = process.env.SHELL || '/bin/bash'; + const shell = process.env.SHELL || "/bin/bash"; const shellName = path.basename(shell); const homeDir = os.homedir(); - - let sourceCmd = ''; - if (shellName.includes('zsh')) { + + let sourceCmd = ""; + if (shellName.includes("zsh")) { sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`; - } else if (shellName.includes('bash')) { + } else if (shellName.includes("bash")) { sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`; } - + if (sourceCmd) { - const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim(); - if (claudePath && claudePath.startsWith('/')) { + const claudePath = execSync(`bash -c "${sourceCmd}"`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); + if (claudePath && claudePath.startsWith("/")) { const version = this.getClaudeVersion(claudePath); - console.log('[ClaudeCliDetector] Found claude via shell config at:', claudePath, 'version:', version); + console.log( + "[ClaudeCliDetector] Found claude via shell config at:", + claudePath, + "version:", + version + ); return { installed: true, path: claudePath, version: version, - method: 'cli' + method: "cli", }; } } @@ -179,21 +224,24 @@ class ClaudeCliDetector { } } - console.log('[ClaudeCliDetector] Claude CLI not found'); + console.log("[ClaudeCliDetector] Claude CLI not found"); return { installed: false, path: null, version: null, - method: 'none' + method: "none", }; } catch (error) { - console.error('[ClaudeCliDetector] Error detecting Claude installation:', error); + console.error( + "[ClaudeCliDetector] Error detecting Claude installation:", + error + ); return { installed: false, path: null, version: null, - method: 'none', - error: error.message + method: "none", + error: error.message, }; } } @@ -206,8 +254,8 @@ class ClaudeCliDetector { static getClaudeVersion(claudePath) { try { const version = execSync(`"${claudePath}" --version 2>/dev/null`, { - encoding: 'utf-8', - timeout: 5000 + encoding: "utf-8", + timeout: 5000, }).trim(); return version || null; } catch (error) { @@ -226,10 +274,10 @@ class ClaudeCliDetector { * @returns {Object} Authentication status */ static getAuthStatus(appCredentialsPath) { - console.log('[ClaudeCliDetector] Checking auth status...'); + console.log("[ClaudeCliDetector] Checking auth status..."); const envApiKey = process.env.ANTHROPIC_API_KEY; - console.log('[ClaudeCliDetector] Env ANTHROPIC_API_KEY:', !!envApiKey); + console.log("[ClaudeCliDetector] Env ANTHROPIC_API_KEY:", !!envApiKey); // Check app's stored credentials let storedOAuthToken = null; @@ -237,38 +285,44 @@ class ClaudeCliDetector { if (appCredentialsPath && fs.existsSync(appCredentialsPath)) { try { - const content = fs.readFileSync(appCredentialsPath, 'utf-8'); + const content = fs.readFileSync(appCredentialsPath, "utf-8"); const credentials = JSON.parse(content); storedOAuthToken = credentials.anthropic_oauth_token || null; - storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null; - console.log('[ClaudeCliDetector] App credentials:', { + storedApiKey = + credentials.anthropic || credentials.anthropic_api_key || null; + console.log("[ClaudeCliDetector] App credentials:", { hasOAuthToken: !!storedOAuthToken, - hasApiKey: !!storedApiKey + hasApiKey: !!storedApiKey, }); } catch (error) { - console.error('[ClaudeCliDetector] Error reading app credentials:', error); + console.error( + "[ClaudeCliDetector] Error reading app credentials:", + error + ); } } // Determine authentication method // Priority: Stored OAuth Token > Stored API Key > Env API Key let authenticated = false; - let method = 'none'; + let method = "none"; if (storedOAuthToken) { authenticated = true; - method = 'oauth_token'; - console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)'); + method = "oauth_token"; + console.log( + "[ClaudeCliDetector] Using stored OAuth token (subscription)" + ); } else if (storedApiKey) { authenticated = true; - method = 'api_key'; - console.log('[ClaudeCliDetector] Using stored API key'); + method = "api_key"; + console.log("[ClaudeCliDetector] Using stored API key"); } else if (envApiKey) { authenticated = true; - method = 'api_key_env'; - console.log('[ClaudeCliDetector] Using environment API key'); + method = "api_key_env"; + console.log("[ClaudeCliDetector] Using environment API key"); } else { - console.log('[ClaudeCliDetector] No authentication found'); + console.log("[ClaudeCliDetector] No authentication found"); } const result = { @@ -276,12 +330,26 @@ class ClaudeCliDetector { method, hasStoredOAuthToken: !!storedOAuthToken, hasStoredApiKey: !!storedApiKey, - hasEnvApiKey: !!envApiKey + hasEnvApiKey: !!envApiKey, }; - console.log('[ClaudeCliDetector] Auth status result:', result); + console.log("[ClaudeCliDetector] Auth status result:", result); return result; } + /** + * Get installation info (installation status only, no auth) + * @returns {Object} Installation info with status property + */ + static getInstallationInfo() { + const installation = this.detectClaudeInstallation(); + return { + status: installation.installed ? "installed" : "not_installed", + installed: installation.installed, + path: installation.path, + version: installation.version, + method: installation.method, + }; + } /** * Get full status including installation and auth @@ -294,12 +362,12 @@ class ClaudeCliDetector { return { success: true, - status: installation.installed ? 'installed' : 'not_installed', + status: installation.installed ? "installed" : "not_installed", installed: installation.installed, path: installation.path, version: installation.version, method: installation.method, - auth + auth, }; } @@ -333,9 +401,9 @@ class ClaudeCliDetector { */ static getInstallCommands() { return { - macos: 'curl -fsSL https://claude.ai/install.sh | bash', - windows: 'irm https://claude.ai/install.ps1 | iex', - linux: 'curl -fsSL https://claude.ai/install.sh | bash' + macos: "curl -fsSL https://claude.ai/install.sh | bash", + windows: "irm https://claude.ai/install.ps1 | iex", + linux: "curl -fsSL https://claude.ai/install.sh | bash", }; } @@ -349,64 +417,69 @@ class ClaudeCliDetector { const platform = process.platform; let command, args; - if (platform === 'win32') { - command = 'powershell'; - args = ['-Command', 'irm https://claude.ai/install.ps1 | iex']; + if (platform === "win32") { + command = "powershell"; + args = ["-Command", "irm https://claude.ai/install.ps1 | iex"]; } else { - command = 'bash'; - args = ['-c', 'curl -fsSL https://claude.ai/install.sh | bash']; + command = "bash"; + args = ["-c", "curl -fsSL https://claude.ai/install.sh | bash"]; } - console.log('[ClaudeCliDetector] Installing Claude CLI...'); + console.log("[ClaudeCliDetector] Installing Claude CLI..."); const proc = spawn(command, args, { - stdio: ['pipe', 'pipe', 'pipe'], - shell: false + stdio: ["pipe", "pipe", "pipe"], + shell: false, }); - let output = ''; - let errorOutput = ''; + let output = ""; + let errorOutput = ""; - proc.stdout.on('data', (data) => { + proc.stdout.on("data", (data) => { const text = data.toString(); output += text; if (onProgress) { - onProgress({ type: 'stdout', data: text }); + onProgress({ type: "stdout", data: text }); } }); - proc.stderr.on('data', (data) => { + proc.stderr.on("data", (data) => { const text = data.toString(); errorOutput += text; if (onProgress) { - onProgress({ type: 'stderr', data: text }); + onProgress({ type: "stderr", data: text }); } }); - proc.on('close', (code) => { + proc.on("close", (code) => { if (code === 0) { - console.log('[ClaudeCliDetector] Installation completed successfully'); + console.log( + "[ClaudeCliDetector] Installation completed successfully" + ); resolve({ success: true, output, - message: 'Claude CLI installed successfully' + message: "Claude CLI installed successfully", }); } else { - console.error('[ClaudeCliDetector] Installation failed with code:', code); + console.error( + "[ClaudeCliDetector] Installation failed with code:", + code + ); reject({ success: false, error: errorOutput || `Installation failed with code ${code}`, - output + output, }); } }); - proc.on('error', (error) => { - console.error('[ClaudeCliDetector] Installation error:', error); + proc.on("error", (error) => { + console.error("[ClaudeCliDetector] Installation error:", error); reject({ success: false, error: error.message, - output + output, }); }); }); @@ -422,22 +495,22 @@ class ClaudeCliDetector { if (!detection.installed) { return { success: false, - error: 'Claude CLI is not installed. Please install it first.', - installCommands: this.getInstallCommands() + error: "Claude CLI is not installed. Please install it first.", + installCommands: this.getInstallCommands(), }; } return { success: true, - command: 'claude setup-token', + command: "claude setup-token", instructions: [ - '1. Open your terminal', - '2. Run: claude setup-token', - '3. Follow the prompts to authenticate', - '4. Copy the token that is displayed', - '5. Paste the token in the field below' + "1. Open your terminal", + "2. Run: claude setup-token", + "3. Follow the prompts to authenticate", + "4. Copy the token that is displayed", + "5. Paste the token in the field below", ], - note: 'This token is from your Claude subscription and allows you to use Claude without API charges.' + note: "This token is from your Claude subscription and allows you to use Claude without API charges.", }; } } diff --git a/app/electron/services/codex-cli-detector.js b/app/electron/services/codex-cli-detector.js index f45a8db2..8a2f2a45 100644 --- a/app/electron/services/codex-cli-detector.js +++ b/app/electron/services/codex-cli-detector.js @@ -72,38 +72,109 @@ class CodexCliDetector { // Check if auth file exists if (fs.existsSync(authPath)) { - const content = fs.readFileSync(authPath, 'utf-8'); - const auth = JSON.parse(content); + console.log('[CodexCliDetector] Auth file exists, reading content...'); + let auth = null; + try { + const content = fs.readFileSync(authPath, 'utf-8'); + auth = JSON.parse(content); + console.log('[CodexCliDetector] Auth file content keys:', Object.keys(auth)); + console.log('[CodexCliDetector] Auth file has token object:', !!auth.token); + if (auth.token) { + console.log('[CodexCliDetector] Token object keys:', Object.keys(auth.token)); + } - // Check for token object structure (from codex auth login) - // Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... } - if (auth.token && typeof auth.token === 'object') { - const token = auth.token; - if (token.Id_token || token.access_token || token.refresh_token || token.id_token) { - return { + // Check for token object structure (from codex auth login) + // Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... } + if (auth.token && typeof auth.token === 'object') { + const token = auth.token; + if (token.Id_token || token.access_token || token.refresh_token || token.id_token) { + const result = { + authenticated: true, + method: 'cli_tokens', // Distinguish token-based auth from API key auth + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + console.log('[CodexCliDetector] Auth result (cli_tokens):', result); + return result; + } + } + + // Check for tokens at root level (alternative structure) + if (auth.access_token || auth.refresh_token || auth.Id_token || auth.id_token) { + const result = { + authenticated: true, + method: 'cli_tokens', // These are tokens, not API keys + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + console.log('[CodexCliDetector] Auth result (cli_tokens - root level):', result); + return result; + } + + // Check for various possible API key fields that codex might use + // Note: access_token is NOT an API key, it's a token, so we check for it above + if (auth.api_key || auth.openai_api_key || auth.apiKey) { + const result = { authenticated: true, method: 'auth_file', hasAuthFile: true, hasEnvKey: !!envApiKey, authPath }; + console.log('[CodexCliDetector] Auth result (auth_file - API key):', result); + return result; } - } - - // Check for various possible auth fields that codex might use - if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) { + } catch (error) { + console.error('[CodexCliDetector] Error reading/parsing auth file:', error.message); + // If we can't parse the file, we can't determine auth status return { - authenticated: true, - method: 'auth_file', - hasAuthFile: true, + authenticated: false, + method: 'none', + hasAuthFile: false, hasEnvKey: !!envApiKey, authPath }; } // Also check if the file has any meaningful content (non-empty object) + // This is a fallback - but we should still try to detect if it's tokens + if (!auth) { + // File exists but couldn't be parsed + return { + authenticated: false, + method: 'none', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + } + const keys = Object.keys(auth); + console.log('[CodexCliDetector] File has content, keys:', keys); if (keys.length > 0) { + // Check again for tokens in case we missed them (maybe nested differently) + const hasTokens = keys.some(key => + key.toLowerCase().includes('token') || + key.toLowerCase().includes('refresh') || + (auth[key] && typeof auth[key] === 'object' && ( + auth[key].access_token || auth[key].refresh_token || auth[key].Id_token || auth[key].id_token + )) + ); + + if (hasTokens) { + const result = { + authenticated: true, + method: 'cli_tokens', + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath + }; + console.log('[CodexCliDetector] Auth result (cli_tokens - fallback detection):', result); + return result; + } + // File exists and has content, likely authenticated // Try to verify by checking if codex command works try { @@ -116,34 +187,45 @@ class CodexCliDetector { timeout: 3000 }); // If command succeeds, assume authenticated - return { + // But check if it's likely tokens vs API key based on file structure + const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh')); + const result = { authenticated: true, - method: 'auth_file', + method: likelyTokens ? 'cli_tokens' : 'auth_file', hasAuthFile: true, hasEnvKey: !!envApiKey, authPath }; + console.log('[CodexCliDetector] Auth result (verified via CLI, method:', result.method, '):', result); + return result; } catch (cmdError) { // Command failed, but file exists - might still be authenticated - // Return authenticated if file has content - return { + // Check if it's likely tokens + const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh')); + const result = { authenticated: true, - method: 'auth_file', + method: likelyTokens ? 'cli_tokens' : 'auth_file', hasAuthFile: true, hasEnvKey: !!envApiKey, authPath }; + console.log('[CodexCliDetector] Auth result (file exists, method:', result.method, '):', result); + return result; } } } catch (verifyError) { // Verification failed, but file exists with content - return { + // Check if it's likely tokens + const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh')); + const result = { authenticated: true, - method: 'auth_file', + method: likelyTokens ? 'cli_tokens' : 'auth_file', hasAuthFile: true, hasEnvKey: !!envApiKey, authPath }; + console.log('[CodexCliDetector] Auth result (fallback, method:', result.method, '):', result); + return result; } } } diff --git a/app/electron/services/codex-config-manager.js b/app/electron/services/codex-config-manager.js index 37832f61..ed324917 100644 --- a/app/electron/services/codex-config-manager.js +++ b/app/electron/services/codex-config-manager.js @@ -349,3 +349,5 @@ class CodexConfigManager { } module.exports = new CodexConfigManager(); + + diff --git a/app/electron/services/mcp-server-stdio.js b/app/electron/services/mcp-server-stdio.js index c0da07d7..44cdd6b4 100644 --- a/app/electron/services/mcp-server-stdio.js +++ b/app/electron/services/mcp-server-stdio.js @@ -345,3 +345,5 @@ process.on('SIGINT', () => { console.error('[McpServerStdio] Starting MCP server for automaker-tools'); console.error(`[McpServerStdio] Project path: ${projectPath}`); console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`); + + diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index fa4cbfdb..e5abe185 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, formatShortcut } from "@/store/app-store"; import { FolderOpen, Plus, @@ -733,7 +733,7 @@ export function Sidebar() { className="ml-1 px-1 py-0.5 bg-brand-500/10 border border-brand-500/30 rounded text-[10px] font-mono text-brand-400/70" data-testid="sidebar-toggle-shortcut" > - {shortcuts.toggleSidebar} + {formatShortcut(shortcuts.toggleSidebar, true)} @@ -790,8 +790,8 @@ export function Sidebar() { data-testid="open-project-button" > - - {shortcuts.openProject} + + {formatShortcut(shortcuts.openProject, true)} + ); + + // Wrap in tooltip if bound + if (isBound) { + return ( + + {keyElement} + +
+ {shortcuts.map((shortcut) => { + const shortcutStr = keyboardShortcuts[shortcut]; + const displayShortcut = formatShortcut(shortcutStr, true); + return ( +
+ + {SHORTCUT_LABELS[shortcut]} + + {displayShortcut} + + {keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && ( + (custom) + )} +
+ ); + })} +
+
+
+ ); + } + + return keyElement; + }; + + return ( + +
+ {/* Legend */} +
+ {Object.entries(CATEGORY_COLORS).map(([key, colors]) => ( +
+
+ {colors.label} +
+ ))} +
+
+ Available +
+
+
+ Modified +
+
+ + {/* Keyboard layout */} +
+ {KEYBOARD_ROWS.map((row, rowIndex) => ( +
+ {row.map(renderKey)} +
+ ))} +
+ + {/* Stats */} +
+ + {Object.keys(keyboardShortcuts).length} shortcuts + configured + + + + {Object.keys(keyToShortcuts).length} + {" "} + keys in use + + + + {KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length} + {" "} + keys available + +
+
+ + ); +} + +// Full shortcut reference panel with editing capability +interface ShortcutReferencePanelProps { + editable?: boolean; +} + +export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) { + const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore(); + const [editingShortcut, setEditingShortcut] = React.useState(null); + const [keyValue, setKeyValue] = React.useState(""); + const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false }); + const [shortcutError, setShortcutError] = React.useState(null); + + const groupedShortcuts = React.useMemo(() => { + const groups: Record> = { + navigation: [], + ui: [], + action: [], + }; + + (Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach( + ([shortcut, category]) => { + groups[category].push({ + key: shortcut, + label: SHORTCUT_LABELS[shortcut], + value: keyboardShortcuts[shortcut], + }); + } + ); + + return groups; + }, [keyboardShortcuts]); + + // Build the full shortcut string from key + modifiers + const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => { + const parts: string[] = []; + if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl"); + if (mods.alt) parts.push(isMac ? "Opt" : "Alt"); + if (mods.shift) parts.push("Shift"); + parts.push(key.toUpperCase()); + return parts.join("+"); + }, []); + + // Check for conflicts with other shortcuts + const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => { + const conflict = Object.entries(keyboardShortcuts).find( + ([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase() + ); + return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null; + }, [keyboardShortcuts]); + + const handleStartEdit = (key: keyof KeyboardShortcuts) => { + const currentValue = keyboardShortcuts[key]; + const parsed = parseShortcut(currentValue); + setEditingShortcut(key); + setKeyValue(parsed.key); + setModifiers({ + shift: parsed.shift || false, + cmdCtrl: parsed.cmdCtrl || false, + alt: parsed.alt || false, + }); + setShortcutError(null); + }; + + const handleSaveShortcut = () => { + if (!editingShortcut || shortcutError || !keyValue) return; + const shortcutStr = buildShortcutString(keyValue, modifiers); + setKeyboardShortcut(editingShortcut, shortcutStr); + setEditingShortcut(null); + setKeyValue(""); + setModifiers({ shift: false, cmdCtrl: false, alt: false }); + setShortcutError(null); + }; + + const handleCancelEdit = () => { + setEditingShortcut(null); + setKeyValue(""); + setModifiers({ shift: false, cmdCtrl: false, alt: false }); + setShortcutError(null); + }; + + const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => { + setKeyValue(value); + // Check for conflicts with full shortcut string + if (!value) { + setShortcutError("Key cannot be empty"); + } else { + const shortcutStr = buildShortcutString(value, modifiers); + const conflictLabel = checkConflict(shortcutStr, currentKey); + if (conflictLabel) { + setShortcutError(`Already used by "${conflictLabel}"`); + } else { + setShortcutError(null); + } + } + }; + + const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => { + // Enforce single modifier: when checking, uncheck all others (radio-button behavior) + const newModifiers = checked + ? { shift: false, cmdCtrl: false, alt: false, [modifier]: true } + : { ...modifiers, [modifier]: false }; + + setModifiers(newModifiers); + + // Recheck for conflicts + if (keyValue) { + const shortcutStr = buildShortcutString(keyValue, newModifiers); + const conflictLabel = checkConflict(shortcutStr, currentKey); + if (conflictLabel) { + setShortcutError(`Already used by "${conflictLabel}"`); + } else { + setShortcutError(null); + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !shortcutError && keyValue) { + handleSaveShortcut(); + } else if (e.key === "Escape") { + handleCancelEdit(); + } + }; + + const handleResetShortcut = (key: keyof KeyboardShortcuts) => { + setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]); + }; + + return ( + +
+ {editable && ( +
+ +
+ )} + {Object.entries(groupedShortcuts).map(([category, shortcuts]) => { + const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS]; + return ( +
+

+ {colors.label} +

+
+ {shortcuts.map(({ key, label, value }) => { + const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key]; + const isEditing = editingShortcut === key; + + return ( +
editable && !isEditing && handleStartEdit(key)} + data-testid={`shortcut-row-${key}`} + > + {label} +
+ {isEditing ? ( +
e.stopPropagation()}> + {/* Modifier checkboxes */} +
+
+ handleModifierChange("cmdCtrl", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ handleModifierChange("alt", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ handleModifierChange("shift", !!checked, key)} + className="h-3.5 w-3.5" + /> + +
+
+ + + handleKeyChange(e.target.value, key)} + onKeyDown={handleKeyDown} + className={cn( + "w-12 h-7 text-center font-mono text-xs uppercase", + shortcutError && "border-red-500 focus-visible:ring-red-500" + )} + placeholder="Key" + maxLength={1} + autoFocus + data-testid={`edit-shortcut-input-${key}`} + /> + + +
+ ) : ( + <> + + {formatShortcut(value, true)} + + {isModified && editable && ( + + + + + + Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]}) + + + )} + {isModified && !editable && ( + + )} + {editable && !isModified && ( + + )} + + )} +
+
+ ); + })} +
+ {editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && ( +

{shortcutError}

+ )} +
+ ); + })} +
+
+ ); +} diff --git a/app/src/components/ui/markdown.tsx b/app/src/components/ui/markdown.tsx index 84473624..5a080e4b 100644 --- a/app/src/components/ui/markdown.tsx +++ b/app/src/components/ui/markdown.tsx @@ -10,7 +10,7 @@ interface MarkdownProps { /** * Reusable Markdown component for rendering markdown content - * Styled for dark mode with proper typography + * Theme-aware styling that adapts to all predefined themes */ export function Markdown({ children, className }: MarkdownProps) { return ( @@ -18,27 +18,27 @@ export function Markdown({ children, className }: MarkdownProps) { className={cn( "prose prose-sm prose-invert max-w-none", // Headings - "[&_h1]:text-xl [&_h1]:text-zinc-200 [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2", - "[&_h2]:text-lg [&_h2]:text-zinc-200 [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2", - "[&_h3]:text-base [&_h3]:text-zinc-200 [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2", - "[&_h4]:text-sm [&_h4]:text-zinc-200 [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1", + "[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2", + "[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2", + "[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2", + "[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1", // Paragraphs - "[&_p]:text-zinc-300 [&_p]:leading-relaxed [&_p]:my-2", + "[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2", // Lists "[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4", - "[&_li]:text-zinc-300 [&_li]:my-0.5", + "[&_li]:text-foreground-secondary [&_li]:my-0.5", // Code - "[&_code]:text-cyan-400 [&_code]:bg-zinc-800/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm", - "[&_pre]:bg-zinc-900/80 [&_pre]:border [&_pre]:border-white/10 [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto", + "[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm", + "[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto", "[&_pre_code]:bg-transparent [&_pre_code]:p-0", // Strong/Bold - "[&_strong]:text-zinc-200 [&_strong]:font-semibold", + "[&_strong]:text-foreground [&_strong]:font-semibold", // Links - "[&_a]:text-blue-400 [&_a]:no-underline hover:[&_a]:underline", + "[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline", // Blockquotes - "[&_blockquote]:border-l-2 [&_blockquote]:border-zinc-600 [&_blockquote]:pl-4 [&_blockquote]:text-zinc-400 [&_blockquote]:italic [&_blockquote]:my-2", + "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2", // Horizontal rules - "[&_hr]:border-zinc-700 [&_hr]:my-4", + "[&_hr]:border-border [&_hr]:my-4", className )} > diff --git a/app/src/components/views/settings-view.tsx b/app/src/components/views/settings-view.tsx index 6df1f9cf..2936505f 100644 --- a/app/src/components/views/settings-view.tsx +++ b/app/src/components/views/settings-view.tsx @@ -1,61 +1,41 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS } from "@/store/app-store"; -import type { KeyboardShortcuts } from "@/store/app-store"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { useAppStore } from "@/store/app-store"; import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; import { - Settings, Key, - Eye, - EyeOff, - CheckCircle2, - AlertCircle, - Loader2, - Zap, - Sun, - Moon, Palette, Terminal, - Ghost, - Snowflake, - Flame, - Sparkles, - Eclipse, - Trees, - Cat, Atom, - Radio, LayoutGrid, - Minimize2, - Square, - Maximize2, FlaskConical, Trash2, - Folder, - GitBranch, - TestTube, Settings2, - RefreshCw, - Info, - RotateCcw, Volume2, VolumeX, } from "lucide-react"; -import { getElectronAPI } from "@/lib/electron"; import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useSetupStore } from "@/store/setup-store"; + +import { useCliStatus } from "./settings-view/hooks/use-cli-status"; +import { useScrollTracking } from "@/hooks/use-scroll-tracking"; +import { SettingsHeader } from "./settings-view/components/settings-header"; +import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog"; +import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog"; +import { SettingsNavigation } from "./settings-view/components/settings-navigation"; +import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section"; +import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status"; +import { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status"; +import { AppearanceSection } from "./settings-view/appearance/appearance-section"; +import { KanbanDisplaySection } from "./settings-view/kanban-display/kanban-display-section"; +import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section"; +import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section"; +import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section"; +import type { + Project as SettingsProject, + Theme, +} from "./settings-view/shared/types"; +import type { Project as ElectronProject } from "@/lib/electron"; // Navigation items for the side panel const NAV_ITEMS = [ @@ -72,9 +52,6 @@ const NAV_ITEMS = [ export function SettingsView() { const { - apiKeys, - setApiKeys, - setCurrentView, theme, setTheme, setProjectTheme, @@ -90,13 +67,25 @@ export function SettingsView() { setMuteDoneSound, currentProject, moveProjectToTrash, - keyboardShortcuts, - setKeyboardShortcut, - resetKeyboardShortcuts, } = useAppStore(); + // Convert electron Project to settings-view Project type + const convertProject = ( + project: ElectronProject | null + ): SettingsProject | null => { + if (!project) return null; + return { + id: project.id, + name: project.name, + path: project.path, + theme: project.theme as Theme | undefined, + }; + }; + + const settingsProject = convertProject(currentProject); + // Compute the effective theme for the current project - const effectiveTheme = currentProject?.theme || theme; + const effectiveTheme = (settingsProject?.theme || theme) as Theme; // Handler to set theme - saves to project if one is selected, otherwise to global const handleSetTheme = (newTheme: typeof theme) => { @@ -106,401 +95,27 @@ export function SettingsView() { setTheme(newTheme); } }; - const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); - const [googleKey, setGoogleKey] = useState(apiKeys.google); - const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); - const [showAnthropicKey, setShowAnthropicKey] = useState(false); - const [showGoogleKey, setShowGoogleKey] = useState(false); - const [showOpenaiKey, setShowOpenaiKey] = useState(false); - const [saved, setSaved] = useState(false); - const [testingConnection, setTestingConnection] = useState(false); - const [testResult, setTestResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - const [testingGeminiConnection, setTestingGeminiConnection] = useState(false); - const [geminiTestResult, setGeminiTestResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - const [claudeCliStatus, setClaudeCliStatus] = useState<{ - success: boolean; - status?: string; - method?: string; - version?: string; - path?: string; - recommendation?: string; - installCommands?: { - macos?: string; - windows?: string; - linux?: string; - npm?: string; - }; - error?: string; - } | null>(null); - const [codexCliStatus, setCodexCliStatus] = useState<{ - success: boolean; - status?: string; - method?: string; - version?: string; - path?: string; - hasApiKey?: boolean; - recommendation?: string; - installCommands?: { - macos?: string; - windows?: string; - linux?: string; - npm?: string; - }; - error?: string; - } | null>(null); - const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); - const [openaiTestResult, setOpenaiTestResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - const [activeSection, setActiveSection] = useState("api-keys"); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); - const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); - const [apiKeyStatus, setApiKeyStatus] = useState<{ - hasAnthropicKey: boolean; - hasOpenAIKey: boolean; - hasGoogleKey: boolean; - } | null>(null); - const [editingShortcut, setEditingShortcut] = useState(null); - const [shortcutValue, setShortcutValue] = useState(""); - const [shortcutError, setShortcutError] = useState(null); - const scrollContainerRef = useRef(null); - // Get authentication status from setup store + // Use CLI status hook const { - claudeAuthStatus, - codexAuthStatus, - setClaudeAuthStatus, - setCodexAuthStatus, - } = useSetupStore(); + claudeCliStatus, + codexCliStatus, + isCheckingClaudeCli, + isCheckingCodexCli, + handleRefreshClaudeCli, + handleRefreshCodexCli, + } = useCliStatus(); - useEffect(() => { - setAnthropicKey(apiKeys.anthropic); - setGoogleKey(apiKeys.google); - setOpenaiKey(apiKeys.openai); - }, [apiKeys]); - - useEffect(() => { - const checkCliStatus = async () => { - const api = getElectronAPI(); - if (api?.checkClaudeCli) { - try { - const status = await api.checkClaudeCli(); - setClaudeCliStatus(status); - } catch (error) { - console.error("Failed to check Claude CLI status:", error); - } - } - if (api?.checkCodexCli) { - try { - const status = await api.checkCodexCli(); - setCodexCliStatus(status); - } catch (error) { - console.error("Failed to check Codex CLI status:", error); - } - } - // Check API key status from environment - if (api?.setup?.getApiKeys) { - try { - const status = await api.setup.getApiKeys(); - if (status.success) { - setApiKeyStatus({ - hasAnthropicKey: status.hasAnthropicKey, - hasOpenAIKey: status.hasOpenAIKey, - hasGoogleKey: status.hasGoogleKey, - }); - } - } catch (error) { - console.error("Failed to check API key status:", error); - } - } - - // Check Claude auth status (re-fetch on mount to ensure persistence) - if (api?.setup?.getClaudeStatus) { - try { - const result = await api.setup.getClaudeStatus(); - if (result.success && result.auth) { - // Cast to any because runtime API returns more properties than type definition - const auth = result.auth as any; - const authStatus = { - authenticated: auth.authenticated, - method: - auth.method === "oauth_token" - ? "oauth" - : auth.method?.includes("api_key") - ? "api_key" - : "none", - hasCredentialsFile: false, - oauthTokenValid: auth.hasStoredOAuthToken, - apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey, - }; - setClaudeAuthStatus(authStatus as any); - } - } catch (error) { - console.error("Failed to check Claude auth status:", error); - } - } - - // Check Codex auth status (re-fetch on mount to ensure persistence) - if (api?.setup?.getCodexStatus) { - try { - const result = await api.setup.getCodexStatus(); - if (result.success && result.auth) { - // Cast to any because runtime API returns more properties than type definition - const auth = result.auth as any; - setCodexAuthStatus({ - authenticated: auth.authenticated, - method: auth.hasEnvApiKey - ? "env" - : auth.hasStoredApiKey - ? "api_key" - : "none", - apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey, - }); - } - } catch (error) { - console.error("Failed to check Codex auth status:", error); - } - } - }; - checkCliStatus(); - }, [setClaudeAuthStatus, setCodexAuthStatus]); - - // Track scroll position to highlight active nav item - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const handleScroll = () => { - const sections = NAV_ITEMS.filter( - (item) => item.id !== "danger" || currentProject - ) - .map((item) => ({ - id: item.id, - element: document.getElementById(item.id), - })) - .filter((s) => s.element); - - const containerRect = container.getBoundingClientRect(); - const scrollTop = container.scrollTop; - const scrollHeight = container.scrollHeight; - const clientHeight = container.clientHeight; - - // Check if scrolled to bottom (within a small threshold) - const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; - - if (isAtBottom && sections.length > 0) { - // If at bottom, highlight the last visible section - setActiveSection(sections[sections.length - 1].id); - return; - } - - for (let i = sections.length - 1; i >= 0; i--) { - const section = sections[i]; - if (section.element) { - const rect = section.element.getBoundingClientRect(); - const relativeTop = rect.top - containerRect.top + scrollTop; - if (scrollTop >= relativeTop - 100) { - setActiveSection(section.id); - break; - } - } - } - }; - - container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - }, [currentProject]); - - const scrollToSection = useCallback((sectionId: string) => { - const element = document.getElementById(sectionId); - if (element && scrollContainerRef.current) { - const container = scrollContainerRef.current; - const containerRect = container.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - const relativeTop = - elementRect.top - containerRect.top + container.scrollTop; - - container.scrollTo({ - top: relativeTop - 24, - behavior: "smooth", - }); - } - }, []); - - const handleTestConnection = async () => { - setTestingConnection(true); - setTestResult(null); - - try { - const response = await fetch("/api/claude/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ apiKey: anthropicKey }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - setTestResult({ - success: true, - message: data.message || "Connection successful! Claude responded.", - }); - } else { - setTestResult({ - success: false, - message: data.error || "Failed to connect to Claude API.", - }); - } - } catch { - setTestResult({ - success: false, - message: "Network error. Please check your connection.", - }); - } finally { - setTestingConnection(false); - } - }; - - const handleTestGeminiConnection = async () => { - setTestingGeminiConnection(true); - setGeminiTestResult(null); - - try { - const response = await fetch("/api/gemini/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ apiKey: googleKey }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - setGeminiTestResult({ - success: true, - message: data.message || "Connection successful! Gemini responded.", - }); - } else { - setGeminiTestResult({ - success: false, - message: data.error || "Failed to connect to Gemini API.", - }); - } - } catch { - setGeminiTestResult({ - success: false, - message: "Network error. Please check your connection.", - }); - } finally { - setTestingGeminiConnection(false); - } - }; - - const handleTestOpenaiConnection = async () => { - setTestingOpenaiConnection(true); - setOpenaiTestResult(null); - - try { - const api = getElectronAPI(); - if (api?.testOpenAIConnection) { - const result = await api.testOpenAIConnection(openaiKey); - if (result.success) { - setOpenaiTestResult({ - success: true, - message: - result.message || "Connection successful! OpenAI API responded.", - }); - } else { - setOpenaiTestResult({ - success: false, - message: result.error || "Failed to connect to OpenAI API.", - }); - } - } else { - // Fallback to web API test - const response = await fetch("/api/openai/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ apiKey: openaiKey }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - setOpenaiTestResult({ - success: true, - message: - data.message || "Connection successful! OpenAI API responded.", - }); - } else { - setOpenaiTestResult({ - success: false, - message: data.error || "Failed to connect to OpenAI API.", - }); - } - } - } catch { - setOpenaiTestResult({ - success: false, - message: "Network error. Please check your connection.", - }); - } finally { - setTestingOpenaiConnection(false); - } - }; - - const handleRefreshClaudeCli = useCallback(async () => { - setIsCheckingClaudeCli(true); - try { - const api = getElectronAPI(); - if (api?.checkClaudeCli) { - const status = await api.checkClaudeCli(); - setClaudeCliStatus(status); - } - } catch (error) { - console.error("Failed to refresh Claude CLI status:", error); - } finally { - setIsCheckingClaudeCli(false); - } - }, []); - - const handleRefreshCodexCli = useCallback(async () => { - setIsCheckingCodexCli(true); - try { - const api = getElectronAPI(); - if (api?.checkCodexCli) { - const status = await api.checkCodexCli(); - setCodexCliStatus(status); - } - } catch (error) { - console.error("Failed to refresh Codex CLI status:", error); - } finally { - setIsCheckingCodexCli(false); - } - }, []); - - const handleSave = () => { - setApiKeys({ - anthropic: anthropicKey, - google: googleKey, - openai: openaiKey, + // Use scroll tracking hook + const { activeSection, scrollToSection, scrollContainerRef } = + useScrollTracking({ + items: NAV_ITEMS, + filterFn: (item) => item.id !== "danger" || !!currentProject, + initialSection: "api-keys", }); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - }; + + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false); return (
{/* Header Section */} -
-
-
-
- -
-
-

Settings

-

- Configure your API keys and preferences -

-
-
-
-
+ {/* Content Area with Sidebar */}
{/* Sticky Side Navigation */} - + {/* Scrollable Content */}
{/* API Keys Section */} -
-
-
- -

- API Keys -

-
-

- Configure your AI provider API keys. Keys are stored locally - in your browser. -

-
-
- {/* Claude/Anthropic API Key */} -
-
- - {apiKeys.anthropic && ( - - )} -
-
-
- setAnthropicKey(e.target.value)} - placeholder="sk-ant-..." - className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="anthropic-api-key-input" - /> - -
- -
-

- Used for Claude AI features. Get your key at{" "} - - console.anthropic.com - - . Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment - variable can be used. -

- {testResult && ( -
- {testResult.success ? ( - - ) : ( - - )} - - {testResult.message} - -
- )} -
- - {/* Google API Key */} -
-
- - {apiKeys.google && ( - - )} -
-
-
- setGoogleKey(e.target.value)} - placeholder="AIza..." - className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="google-api-key-input" - /> - -
- -
-

- Used for Gemini AI features (including image/design - prompts). Get your key at{" "} - - makersuite.google.com - -

- {geminiTestResult && ( -
- {geminiTestResult.success ? ( - - ) : ( - - )} - - {geminiTestResult.message} - -
- )} -
- - {/* OpenAI API Key */} -
-
- - {apiKeys.openai && ( - - )} -
-
-
- setOpenaiKey(e.target.value)} - placeholder="sk-..." - className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground" - data-testid="openai-api-key-input" - /> - -
- -
-

- Used for OpenAI Codex CLI and GPT models. Get your key at{" "} - - platform.openai.com - -

- {openaiTestResult && ( -
- {openaiTestResult.success ? ( - - ) : ( - - )} - - {openaiTestResult.message} - -
- )} -
- - {/* Authentication Status Display */} -
-
- - -
- -
- {/* Claude Authentication Status */} -
-
- - - Claude (Anthropic) - -
-
- {claudeAuthStatus?.authenticated ? ( - <> -
- - - Method:{" "} - - {claudeAuthStatus.method === "oauth" - ? "OAuth Token" - : claudeAuthStatus.method === "api_key" - ? "API Key" - : "Unknown"} - - -
- {claudeAuthStatus.oauthTokenValid && ( -
- - OAuth token configured -
- )} - {claudeAuthStatus.apiKeyValid && ( -
- - API key configured -
- )} - {apiKeyStatus?.hasAnthropicKey && ( -
- - Environment variable detected -
- )} - {apiKeys.anthropic && ( -
- - Manual API key in settings -
- )} - - ) : apiKeyStatus?.hasAnthropicKey ? ( -
- - - Using environment variable (ANTHROPIC_API_KEY) - -
- ) : apiKeys.anthropic ? ( -
- - Using manual API key from settings -
- ) : ( -
- - Not Setup -
- )} -
-
- - {/* Codex/OpenAI Authentication Status */} -
-
- - - Codex (OpenAI) - -
-
- {codexAuthStatus?.authenticated ? ( - <> -
- - - Method:{" "} - - {codexAuthStatus.method === "api_key" - ? "API Key (Auth File)" - : codexAuthStatus.method === "env" - ? "API Key (Environment)" - : "Unknown"} - - -
- {codexAuthStatus.apiKeyValid && ( -
- - API key configured -
- )} - {apiKeyStatus?.hasOpenAIKey && ( -
- - Environment variable detected -
- )} - {apiKeys.openai && ( -
- - Manual API key in settings -
- )} - - ) : apiKeyStatus?.hasOpenAIKey ? ( -
- - - Using environment variable (OPENAI_API_KEY) - -
- ) : apiKeys.openai ? ( -
- - Using manual API key from settings -
- ) : ( -
- - Not Setup -
- )} -
-
- - {/* Google/Gemini Authentication Status */} -
-
- - - Gemini (Google) - -
-
- {apiKeyStatus?.hasGoogleKey ? ( -
- - - Using environment variable (GOOGLE_API_KEY) - -
- ) : apiKeys.google ? ( -
- - Using manual API key from settings -
- ) : ( -
- - Not Setup -
- )} -
-
-
-
- - {/* Security Notice */} -
- -
-

- Security Notice -

-

- API keys are stored in your browser's local storage. - Never share your API keys or commit them to version - control. -

-
-
-
-
+ {/* Claude CLI Status Section */} {claudeCliStatus && ( -
-
-
-
- -

- Claude Code CLI -

-
- -
-

- Claude Code CLI provides better performance for long-running - tasks, especially with ultrathink. -

-
-
- {claudeCliStatus.success && - claudeCliStatus.status === "installed" ? ( -
-
- -
-

- Claude Code CLI Installed -

-
- {claudeCliStatus.method && ( -

- Method:{" "} - - {claudeCliStatus.method} - -

- )} - {claudeCliStatus.version && ( -

- Version:{" "} - - {claudeCliStatus.version} - -

- )} - {claudeCliStatus.path && ( -

- Path:{" "} - - {claudeCliStatus.path} - -

- )} -
-
-
- {claudeCliStatus.recommendation && ( -

- {claudeCliStatus.recommendation} -

- )} -
- ) : ( -
-
- -
-

- Claude Code CLI Not Detected -

-

- {claudeCliStatus.recommendation || - "Consider installing Claude Code CLI for optimal performance with ultrathink."} -

-
-
- {claudeCliStatus.installCommands && ( -
-

- Installation Commands: -

-
- {claudeCliStatus.installCommands.npm && ( -
-

- npm: -

- - {claudeCliStatus.installCommands.npm} - -
- )} - {claudeCliStatus.installCommands.macos && ( -
-

- macOS/Linux: -

- - {claudeCliStatus.installCommands.macos} - -
- )} - {claudeCliStatus.installCommands.windows && ( -
-

- Windows (PowerShell): -

- - {claudeCliStatus.installCommands.windows} - -
- )} -
-
- )} -
- )} -
-
+ )} {/* Codex CLI Status Section */} {codexCliStatus && ( -
-
-
-
- -

- OpenAI Codex CLI -

-
- -
-

- Codex CLI enables GPT-5.1 Codex models for autonomous coding - tasks. -

-
-
- {codexCliStatus.success && - codexCliStatus.status === "installed" ? ( -
-
- -
-

- Codex CLI Installed -

-
- {codexCliStatus.method && ( -

- Method:{" "} - - {codexCliStatus.method} - -

- )} - {codexCliStatus.version && ( -

- Version:{" "} - - {codexCliStatus.version} - -

- )} - {codexCliStatus.path && ( -

- Path:{" "} - - {codexCliStatus.path} - -

- )} -
-
-
- {codexCliStatus.recommendation && ( -

- {codexCliStatus.recommendation} -

- )} -
- ) : codexCliStatus.status === "api_key_only" ? ( -
-
- -
-

- API Key Detected - CLI Not Installed -

-

- {codexCliStatus.recommendation || - "OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."} -

-
-
- {codexCliStatus.installCommands && ( -
-

- Installation Commands: -

-
- {codexCliStatus.installCommands.npm && ( -
-

- npm: -

- - {codexCliStatus.installCommands.npm} - -
- )} -
-
- )} -
- ) : ( -
-
- -
-

- Codex CLI Not Detected -

-

- {codexCliStatus.recommendation || - "Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."} -

-
-
- {codexCliStatus.installCommands && ( -
-

- Installation Commands: -

-
- {codexCliStatus.installCommands.npm && ( -
-

- npm: -

- - {codexCliStatus.installCommands.npm} - -
- )} - {codexCliStatus.installCommands.macos && ( -
-

- macOS (Homebrew): -

- - {codexCliStatus.installCommands.macos} - -
- )} -
-
- )} -
- )} -
-
+ )} {/* Appearance Section */} -
-
-
- -

- Appearance -

-
-

- Customize the look and feel of your application. -

-
-
-
- -
- - - - - - - - - - - - -
-
-
-
+ {/* Kanban Card Display Section */} -
-
-
- -

- Kanban Card Display -

-
-

- Control how much information is displayed on Kanban cards. -

-
-
-
- -
- - - -
-

- Minimal: Shows only title and category -
- Standard: Adds steps preview and progress - bar -
- Detailed: Shows all info including model, - tool calls, task list, and summaries -

-
-
-
+ {/* Keyboard Shortcuts Section */} -
-
-
- -

- Keyboard Shortcuts -

-
-

- Customize keyboard shortcuts for navigation and actions. Click - on any shortcut to edit it. -

-
-
- {/* Navigation Shortcuts */} -
-
-

- Navigation -

- -
-
- {[ - { - key: "board" as keyof KeyboardShortcuts, - label: "Kanban Board", - }, - { - key: "agent" as keyof KeyboardShortcuts, - label: "Agent Runner", - }, - { - key: "spec" as keyof KeyboardShortcuts, - label: "Spec Editor", - }, - { - key: "context" as keyof KeyboardShortcuts, - label: "Context", - }, - { - key: "tools" as keyof KeyboardShortcuts, - label: "Agent Tools", - }, - { - key: "profiles" as keyof KeyboardShortcuts, - label: "AI Profiles", - }, - { - key: "settings" as keyof KeyboardShortcuts, - label: "Settings", - }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value.toUpperCase(); - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries( - keyboardShortcuts - ).find( - ([k, v]) => - k !== key && v.toUpperCase() === value - ); - if (conflict) { - setShortcutError( - `Already used by ${conflict[0]}` - ); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !shortcutError && - shortcutValue - ) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== - DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - - (modified) - - )} - - )} -
-
- ))} -
- {shortcutError && ( -

{shortcutError}

- )} -
- - {/* UI Shortcuts */} -
-

- UI Controls -

-
- {[ - { - key: "toggleSidebar" as keyof KeyboardShortcuts, - label: "Toggle Sidebar", - }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value; - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries( - keyboardShortcuts - ).find(([k, v]) => k !== key && v === value); - if (conflict) { - setShortcutError( - `Already used by ${conflict[0]}` - ); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !shortcutError && - shortcutValue - ) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== - DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - - (modified) - - )} - - )} -
-
- ))} -
-
- - {/* Action Shortcuts */} -
-

- Actions -

-
- {[ - { - key: "addFeature" as keyof KeyboardShortcuts, - label: "Add Feature", - }, - { - key: "addContextFile" as keyof KeyboardShortcuts, - label: "Add Context File", - }, - { - key: "startNext" as keyof KeyboardShortcuts, - label: "Start Next Features", - }, - { - key: "newSession" as keyof KeyboardShortcuts, - label: "New Session", - }, - { - key: "openProject" as keyof KeyboardShortcuts, - label: "Open Project", - }, - { - key: "projectPicker" as keyof KeyboardShortcuts, - label: "Project Picker", - }, - { - key: "cyclePrevProject" as keyof KeyboardShortcuts, - label: "Previous Project", - }, - { - key: "cycleNextProject" as keyof KeyboardShortcuts, - label: "Next Project", - }, - { - key: "addProfile" as keyof KeyboardShortcuts, - label: "Add Profile", - }, - ].map(({ key, label }) => ( -
- {label} -
- {editingShortcut === key ? ( - <> - { - const value = e.target.value.toUpperCase(); - setShortcutValue(value); - // Check for conflicts - const conflict = Object.entries( - keyboardShortcuts - ).find( - ([k, v]) => - k !== key && v.toUpperCase() === value - ); - if (conflict) { - setShortcutError( - `Already used by ${conflict[0]}` - ); - } else { - setShortcutError(null); - } - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !shortcutError && - shortcutValue - ) { - setKeyboardShortcut(key, shortcutValue); - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } else if (e.key === "Escape") { - setEditingShortcut(null); - setShortcutValue(""); - setShortcutError(null); - } - }} - className="w-24 h-8 text-center font-mono" - placeholder="Key" - maxLength={2} - autoFocus - data-testid={`edit-shortcut-${key}`} - /> - - - - ) : ( - <> - - {keyboardShortcuts[key] !== - DEFAULT_KEYBOARD_SHORTCUTS[key] && ( - - (modified) - - )} - - )} -
-
- ))} -
-
- - {/* Information */} -
- -
-

- About Keyboard Shortcuts -

-

- Shortcuts won't trigger when typing in input fields. - Use single keys (A-Z, 0-9) or special keys like ` - (backtick). Changes take effect immediately. -

-
-
-
-
+ setShowKeyboardMapDialog(true)} + /> {/* Audio Section */}
{/* Feature Defaults Section */} -
-
-
- -

- Feature Defaults -

-
-

- Configure default settings for new features. -

-
-
- {/* Profiles Only Setting */} -
-
- - setShowProfilesOnly(checked === true) - } - className="mt-0.5" - data-testid="show-profiles-only-checkbox" - /> -
- -

- When enabled, the Add Feature dialog will show only AI - profiles and hide advanced model tweaking options - (Claude SDK, thinking levels, and OpenAI Codex CLI). - This creates a cleaner, less overwhelming UI. You can - always disable this to access advanced settings. -

-
-
-
+ - {/* Separator */} -
- - {/* Skip Tests Setting */} -
-
- - setDefaultSkipTests(checked === true) - } - className="mt-0.5" - data-testid="default-skip-tests-checkbox" - /> -
- -

- When enabled, new features will default to manual - verification instead of TDD (test-driven development). - You can still override this for individual features. -

-
-
-
- - {/* Worktree Isolation Setting */} -
-
- - setUseWorktrees(checked === true) - } - className="mt-0.5" - data-testid="use-worktrees-checkbox" - /> -
- -

- Creates isolated git branches for each feature. When - disabled, agents work directly in the main project - directory. This feature is experimental and may require - additional setup like branch selection and merge - configuration. -

-
-
-
-
-
- - {/* Delete Project Section - Only show when a project is selected */} - {currentProject && ( -
-
-
- -

- Danger Zone -

-
-

- Permanently remove this project from Automaker. -

-
-
-
-
-
- -
-
-

- {currentProject.name} -

-

- {currentProject.path} -

-
-
- -
-
-
- )} - - {/* Save Button */} -
- - -
+ {/* Danger Zone Section - Only show when a project is selected */} + setShowDeleteDialog(true)} + />
+ {/* Keyboard Map Dialog */} + + {/* Delete Project Confirmation Dialog */} - - - - - - Delete Project - - - Are you sure you want to move this project to Trash? - - - - {currentProject && ( -
-
- -
-
-

- {currentProject.name} -

-

- {currentProject.path} -

-
-
- )} - -

- The folder will remain on disk until you permanently delete it from - Trash. -

- - - - - -
-
+
); } diff --git a/app/src/components/views/settings-view/api-keys/api-key-field.tsx b/app/src/components/views/settings-view/api-keys/api-key-field.tsx new file mode 100644 index 00000000..db281813 --- /dev/null +++ b/app/src/components/views/settings-view/api-keys/api-key-field.tsx @@ -0,0 +1,117 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from "lucide-react"; +import type { ProviderConfig } from "@/config/api-providers"; + +interface ApiKeyFieldProps { + config: ProviderConfig; +} + +export function ApiKeyField({ config }: ApiKeyFieldProps) { + const { + label, + inputId, + placeholder, + value, + setValue, + showValue, + setShowValue, + hasStoredKey, + inputTestId, + toggleTestId, + testButton, + result, + resultTestId, + resultMessageTestId, + descriptionPrefix, + descriptionLinkHref, + descriptionLinkText, + descriptionSuffix, + } = config; + + return ( +
+
+ + {hasStoredKey && } +
+
+
+ setValue(e.target.value)} + placeholder={placeholder} + className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground" + data-testid={inputTestId} + /> + +
+ +
+

+ {descriptionPrefix}{" "} + + {descriptionLinkText} + + {descriptionSuffix} +

+ {result && ( +
+ {result.success ? ( + + ) : ( + + )} + + {result.message} + +
+ )} +
+ ); +} diff --git a/app/src/components/views/settings-view/api-keys/api-keys-section.tsx b/app/src/components/views/settings-view/api-keys/api-keys-section.tsx new file mode 100644 index 00000000..33c89f8a --- /dev/null +++ b/app/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -0,0 +1,72 @@ +import { useAppStore } from "@/store/app-store"; +import { useSetupStore } from "@/store/setup-store"; +import { Button } from "@/components/ui/button"; +import { Key, CheckCircle2 } from "lucide-react"; +import { ApiKeyField } from "./api-key-field"; +import { buildProviderConfigs } from "@/config/api-providers"; +import { AuthenticationStatusDisplay } from "./authentication-status-display"; +import { SecurityNotice } from "./security-notice"; +import { useApiKeyManagement } from "./hooks/use-api-key-management"; + +export function ApiKeysSection() { + const { apiKeys } = useAppStore(); + const { claudeAuthStatus, codexAuthStatus } = useSetupStore(); + + const { providerConfigParams, apiKeyStatus, handleSave, saved } = + useApiKeyManagement(); + + const providerConfigs = buildProviderConfigs(providerConfigParams); + + return ( +
+
+
+ +

API Keys

+
+

+ Configure your AI provider API keys. Keys are stored locally in your + browser. +

+
+
+ {/* API Key Fields */} + {providerConfigs.map((provider) => ( + + ))} + + {/* Authentication Status Display */} + + + {/* Security Notice */} + + + {/* Save Button */} +
+ +
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx b/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx new file mode 100644 index 00000000..515db7c7 --- /dev/null +++ b/app/src/components/views/settings-view/api-keys/authentication-status-display.tsx @@ -0,0 +1,212 @@ +import { Label } from "@/components/ui/label"; +import { + CheckCircle2, + AlertCircle, + Info, + Terminal, + Atom, + Sparkles, +} from "lucide-react"; +import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store"; + +interface AuthenticationStatusDisplayProps { + claudeAuthStatus: ClaudeAuthStatus | null; + codexAuthStatus: CodexAuthStatus | null; + apiKeyStatus: { + hasAnthropicKey: boolean; + hasOpenAIKey: boolean; + hasGoogleKey: boolean; + } | null; + apiKeys: { + anthropic: string; + google: string; + openai: string; + }; +} + +export function AuthenticationStatusDisplay({ + claudeAuthStatus, + codexAuthStatus, + apiKeyStatus, + apiKeys, +}: AuthenticationStatusDisplayProps) { + return ( +
+
+ + +
+ +
+ {/* Claude Authentication Status */} +
+
+ + + Claude (Anthropic) + +
+
+ {claudeAuthStatus?.authenticated ? ( + <> +
+ + + Method:{" "} + + {claudeAuthStatus.method === "oauth" + ? "OAuth Token" + : claudeAuthStatus.method === "api_key" + ? "API Key" + : "Unknown"} + + +
+ {claudeAuthStatus.oauthTokenValid && ( +
+ + OAuth token configured +
+ )} + {claudeAuthStatus.apiKeyValid && ( +
+ + API key configured +
+ )} + {apiKeyStatus?.hasAnthropicKey && ( +
+ + Environment variable detected +
+ )} + {apiKeys.anthropic && ( +
+ + Manual API key in settings +
+ )} + + ) : apiKeyStatus?.hasAnthropicKey ? ( +
+ + Using environment variable (ANTHROPIC_API_KEY) +
+ ) : apiKeys.anthropic ? ( +
+ + Using manual API key from settings +
+ ) : ( +
+ + Not Setup +
+ )} +
+
+ + {/* Codex/OpenAI Authentication Status */} +
+
+ + + Codex (OpenAI) + +
+
+ {codexAuthStatus?.authenticated ? ( + <> +
+ + + Method:{" "} + + {codexAuthStatus.method === "cli_verified" || + codexAuthStatus.method === "cli_tokens" + ? "CLI Login (OpenAI Account)" + : codexAuthStatus.method === "api_key" + ? "API Key (Auth File)" + : codexAuthStatus.method === "env" + ? "API Key (Environment)" + : "Unknown"} + + +
+ {codexAuthStatus.method === "cli_verified" || + codexAuthStatus.method === "cli_tokens" ? ( +
+ + Account authenticated +
+ ) : codexAuthStatus.apiKeyValid ? ( +
+ + API key configured +
+ ) : null} + {apiKeyStatus?.hasOpenAIKey && ( +
+ + Environment variable detected +
+ )} + {apiKeys.openai && ( +
+ + Manual API key in settings +
+ )} + + ) : apiKeyStatus?.hasOpenAIKey ? ( +
+ + Using environment variable (OPENAI_API_KEY) +
+ ) : apiKeys.openai ? ( +
+ + Using manual API key from settings +
+ ) : ( +
+ + Not Setup +
+ )} +
+
+ + {/* Google/Gemini Authentication Status */} +
+
+ + + Gemini (Google) + +
+
+ {apiKeyStatus?.hasGoogleKey ? ( +
+ + Using environment variable (GOOGLE_API_KEY) +
+ ) : apiKeys.google ? ( +
+ + Using manual API key from settings +
+ ) : ( +
+ + Not Setup +
+ )} +
+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/app/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts new file mode 100644 index 00000000..f45939ed --- /dev/null +++ b/app/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -0,0 +1,265 @@ +import { useState, useEffect } from "react"; +import { useAppStore } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import type { ProviderConfigParams } from "@/config/api-providers"; + +interface TestResult { + success: boolean; + message: string; +} + +interface ApiKeyStatus { + hasAnthropicKey: boolean; + hasOpenAIKey: boolean; + hasGoogleKey: boolean; +} + +/** + * Custom hook for managing API key state and operations + * Handles input values, visibility toggles, connection testing, and saving + */ +export function useApiKeyManagement() { + const { apiKeys, setApiKeys } = useAppStore(); + + // API key values + const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); + const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + + // Visibility toggles + const [showAnthropicKey, setShowAnthropicKey] = useState(false); + const [showGoogleKey, setShowGoogleKey] = useState(false); + const [showOpenaiKey, setShowOpenaiKey] = useState(false); + + // Test connection states + const [testingConnection, setTestingConnection] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testingGeminiConnection, setTestingGeminiConnection] = useState(false); + const [geminiTestResult, setGeminiTestResult] = useState( + null + ); + const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); + const [openaiTestResult, setOpenaiTestResult] = useState( + null + ); + + // API key status from environment + const [apiKeyStatus, setApiKeyStatus] = useState(null); + + // Save state + const [saved, setSaved] = useState(false); + + // Sync local state with store + useEffect(() => { + setAnthropicKey(apiKeys.anthropic); + setGoogleKey(apiKeys.google); + setOpenaiKey(apiKeys.openai); + }, [apiKeys]); + + // Check API key status from environment on mount + useEffect(() => { + const checkApiKeyStatus = async () => { + const api = getElectronAPI(); + if (api?.setup?.getApiKeys) { + try { + const status = await api.setup.getApiKeys(); + if (status.success) { + setApiKeyStatus({ + hasAnthropicKey: status.hasAnthropicKey, + hasOpenAIKey: status.hasOpenAIKey, + hasGoogleKey: status.hasGoogleKey, + }); + } + } catch (error) { + console.error("Failed to check API key status:", error); + } + } + }; + checkApiKeyStatus(); + }, []); + + // Test Anthropic/Claude connection + const handleTestAnthropicConnection = async () => { + setTestingConnection(true); + setTestResult(null); + + try { + const response = await fetch("/api/claude/test", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ apiKey: anthropicKey }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + setTestResult({ + success: true, + message: data.message || "Connection successful! Claude responded.", + }); + } else { + setTestResult({ + success: false, + message: data.error || "Failed to connect to Claude API.", + }); + } + } catch { + setTestResult({ + success: false, + message: "Network error. Please check your connection.", + }); + } finally { + setTestingConnection(false); + } + }; + + // Test Google/Gemini connection + const handleTestGeminiConnection = async () => { + setTestingGeminiConnection(true); + setGeminiTestResult(null); + + try { + const response = await fetch("/api/gemini/test", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ apiKey: googleKey }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + setGeminiTestResult({ + success: true, + message: data.message || "Connection successful! Gemini responded.", + }); + } else { + setGeminiTestResult({ + success: false, + message: data.error || "Failed to connect to Gemini API.", + }); + } + } catch { + setGeminiTestResult({ + success: false, + message: "Network error. Please check your connection.", + }); + } finally { + setTestingGeminiConnection(false); + } + }; + + // Test OpenAI connection + const handleTestOpenaiConnection = async () => { + setTestingOpenaiConnection(true); + setOpenaiTestResult(null); + + try { + const api = getElectronAPI(); + if (api?.testOpenAIConnection) { + const result = await api.testOpenAIConnection(openaiKey); + if (result.success) { + setOpenaiTestResult({ + success: true, + message: + result.message || "Connection successful! OpenAI API responded.", + }); + } else { + setOpenaiTestResult({ + success: false, + message: result.error || "Failed to connect to OpenAI API.", + }); + } + } else { + // Fallback to web API test + const response = await fetch("/api/openai/test", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ apiKey: openaiKey }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + setOpenaiTestResult({ + success: true, + message: + data.message || "Connection successful! OpenAI API responded.", + }); + } else { + setOpenaiTestResult({ + success: false, + message: data.error || "Failed to connect to OpenAI API.", + }); + } + } + } catch { + setOpenaiTestResult({ + success: false, + message: "Network error. Please check your connection.", + }); + } finally { + setTestingOpenaiConnection(false); + } + }; + + // Save API keys + const handleSave = () => { + setApiKeys({ + anthropic: anthropicKey, + google: googleKey, + openai: openaiKey, + }); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + // Build provider config params for buildProviderConfigs + const providerConfigParams: ProviderConfigParams = { + apiKeys, + anthropic: { + value: anthropicKey, + setValue: setAnthropicKey, + show: showAnthropicKey, + setShow: setShowAnthropicKey, + testing: testingConnection, + onTest: handleTestAnthropicConnection, + result: testResult, + }, + google: { + value: googleKey, + setValue: setGoogleKey, + show: showGoogleKey, + setShow: setShowGoogleKey, + testing: testingGeminiConnection, + onTest: handleTestGeminiConnection, + result: geminiTestResult, + }, + openai: { + value: openaiKey, + setValue: setOpenaiKey, + show: showOpenaiKey, + setShow: setShowOpenaiKey, + testing: testingOpenaiConnection, + onTest: handleTestOpenaiConnection, + result: openaiTestResult, + }, + }; + + return { + // Provider config params for buildProviderConfigs + providerConfigParams, + + // API key status from environment + apiKeyStatus, + + // Save handler and state + handleSave, + saved, + }; +} diff --git a/app/src/components/views/settings-view/api-keys/security-notice.tsx b/app/src/components/views/settings-view/api-keys/security-notice.tsx new file mode 100644 index 00000000..0d6357ce --- /dev/null +++ b/app/src/components/views/settings-view/api-keys/security-notice.tsx @@ -0,0 +1,21 @@ +import { AlertCircle } from "lucide-react"; + +interface SecurityNoticeProps { + title?: string; + message?: string; +} + +export function SecurityNotice({ + title = "Security Notice", + message = "API keys are stored in your browser's local storage. Never share your API keys or commit them to version control.", +}: SecurityNoticeProps) { + return ( +
+ +
+

{title}

+

{message}

+
+
+ ); +} diff --git a/app/src/components/views/settings-view/appearance/appearance-section.tsx b/app/src/components/views/settings-view/appearance/appearance-section.tsx new file mode 100644 index 00000000..90a5c8de --- /dev/null +++ b/app/src/components/views/settings-view/appearance/appearance-section.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Palette } from "lucide-react"; +import { themeOptions } from "@/config/theme-options"; +import type { Theme, Project } from "../shared/types"; + +interface AppearanceSectionProps { + effectiveTheme: Theme; + currentProject: Project | null; + onThemeChange: (theme: Theme) => void; +} + +export function AppearanceSection({ + effectiveTheme, + currentProject, + onThemeChange, +}: AppearanceSectionProps) { + return ( +
+
+
+ +

Appearance

+
+

+ Customize the look and feel of your application. +

+
+
+
+ +
+ {themeOptions.map(({ value, label, Icon, testId }) => { + const isActive = effectiveTheme === value; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/app/src/components/views/settings-view/cli-status/claude-cli-status.tsx new file mode 100644 index 00000000..868024a5 --- /dev/null +++ b/app/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -0,0 +1,148 @@ +import { Button } from "@/components/ui/button"; +import { + Terminal, + CheckCircle2, + AlertCircle, + RefreshCw, +} from "lucide-react"; +import type { CliStatus } from "../shared/types"; + +interface CliStatusProps { + status: CliStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +export function ClaudeCliStatus({ + status, + isChecking, + onRefresh, +}: CliStatusProps) { + if (!status) return null; + + return ( +
+
+
+
+ +

+ Claude Code CLI +

+
+ +
+

+ Claude Code CLI provides better performance for long-running tasks, + especially with ultrathink. +

+
+
+ {status.success && status.status === "installed" ? ( +
+
+ +
+

+ Claude Code CLI Installed +

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version:{" "} + {status.version} +

+ )} + {status.path && ( +

+ Path:{" "} + + {status.path} + +

+ )} +
+
+
+ {status.recommendation && ( +

+ {status.recommendation} +

+ )} +
+ ) : ( +
+
+ +
+

+ Claude Code CLI Not Detected +

+

+ {status.recommendation || + "Consider installing Claude Code CLI for optimal performance with ultrathink."} +

+
+
+ {status.installCommands && ( +
+

+ Installation Commands: +

+
+ {status.installCommands.npm && ( +
+

npm:

+ + {status.installCommands.npm} + +
+ )} + {status.installCommands.macos && ( +
+

+ macOS/Linux: +

+ + {status.installCommands.macos} + +
+ )} + {status.installCommands.windows && ( +
+

+ Windows (PowerShell): +

+ + {status.installCommands.windows} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/app/src/components/views/settings-view/cli-status/codex-cli-status.tsx b/app/src/components/views/settings-view/cli-status/codex-cli-status.tsx new file mode 100644 index 00000000..5f0bde25 --- /dev/null +++ b/app/src/components/views/settings-view/cli-status/codex-cli-status.tsx @@ -0,0 +1,169 @@ +import { Button } from "@/components/ui/button"; +import { + Terminal, + CheckCircle2, + AlertCircle, + RefreshCw, +} from "lucide-react"; +import type { CliStatus } from "../shared/types"; + +interface CliStatusProps { + status: CliStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +export function CodexCliStatus({ + status, + isChecking, + onRefresh, +}: CliStatusProps) { + if (!status) return null; + + return ( +
+
+
+
+ +

+ OpenAI Codex CLI +

+
+ +
+

+ Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks. +

+
+
+ {status.success && status.status === "installed" ? ( +
+
+ +
+

+ Codex CLI Installed +

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version:{" "} + {status.version} +

+ )} + {status.path && ( +

+ Path:{" "} + + {status.path} + +

+ )} +
+
+
+ {status.recommendation && ( +

+ {status.recommendation} +

+ )} +
+ ) : status.status === "api_key_only" ? ( +
+
+ +
+

+ API Key Detected - CLI Not Installed +

+

+ {status.recommendation || + "OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."} +

+
+
+ {status.installCommands && ( +
+

+ Installation Commands: +

+
+ {status.installCommands.npm && ( +
+

npm:

+ + {status.installCommands.npm} + +
+ )} +
+
+ )} +
+ ) : ( +
+
+ +
+

+ Codex CLI Not Detected +

+

+ {status.recommendation || + "Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."} +

+
+
+ {status.installCommands && ( +
+

+ Installation Commands: +

+
+ {status.installCommands.npm && ( +
+

npm:

+ + {status.installCommands.npm} + +
+ )} + {status.installCommands.macos && ( +
+

+ macOS (Homebrew): +

+ + {status.installCommands.macos} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/app/src/components/views/settings-view/components/delete-project-dialog.tsx b/app/src/components/views/settings-view/components/delete-project-dialog.tsx new file mode 100644 index 00000000..0ac5870b --- /dev/null +++ b/app/src/components/views/settings-view/components/delete-project-dialog.tsx @@ -0,0 +1,78 @@ +import { Trash2, Folder } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import type { Project } from "@/store/app-store"; + +interface DeleteProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + project: Project | null; + onConfirm: (projectId: string) => void; +} + +export function DeleteProjectDialog({ + open, + onOpenChange, + project, + onConfirm, +}: DeleteProjectDialogProps) { + const handleConfirm = () => { + if (project) { + onConfirm(project.id); + onOpenChange(false); + } + }; + + return ( + + + + + + Delete Project + + + Are you sure you want to move this project to Trash? + + + + {project && ( +
+
+ +
+
+

{project.name}

+

{project.path}

+
+
+ )} + +

+ The folder will remain on disk until you permanently delete it from Trash. +

+ + + + + +
+
+ ); +} diff --git a/app/src/components/views/settings-view/components/keyboard-map-dialog.tsx b/app/src/components/views/settings-view/components/keyboard-map-dialog.tsx new file mode 100644 index 00000000..b69a37cb --- /dev/null +++ b/app/src/components/views/settings-view/components/keyboard-map-dialog.tsx @@ -0,0 +1,46 @@ +import { Keyboard } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { KeyboardMap, ShortcutReferencePanel } from "@/components/ui/keyboard-map"; + +interface KeyboardMapDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function KeyboardMapDialog({ open, onOpenChange }: KeyboardMapDialogProps) { + return ( + + + + + + Keyboard Shortcut Map + + + Visual overview of all keyboard shortcuts. Keys in color are bound to + shortcuts. Click on any shortcut below to edit it. + + + +
+ {/* Visual Keyboard Map */} + + + {/* Shortcut Reference - Editable */} +
+

+ All Shortcuts Reference (Click to Edit) +

+ +
+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/components/settings-header.tsx b/app/src/components/views/settings-view/components/settings-header.tsx new file mode 100644 index 00000000..163c9148 --- /dev/null +++ b/app/src/components/views/settings-view/components/settings-header.tsx @@ -0,0 +1,27 @@ +import { Settings } from "lucide-react"; + +interface SettingsHeaderProps { + title?: string; + description?: string; +} + +export function SettingsHeader({ + title = "Settings", + description = "Configure your API keys and preferences", +}: SettingsHeaderProps) { + return ( +
+
+
+
+ +
+
+

{title}

+

{description}

+
+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/components/settings-navigation.tsx b/app/src/components/views/settings-view/components/settings-navigation.tsx new file mode 100644 index 00000000..7a90b7ca --- /dev/null +++ b/app/src/components/views/settings-view/components/settings-navigation.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import type { Project } from "@/store/app-store"; +import type { NavigationItem } from "../config/navigation"; + +interface SettingsNavigationProps { + navItems: NavigationItem[]; + activeSection: string; + currentProject: Project | null; + onNavigate: (sectionId: string) => void; +} + +export function SettingsNavigation({ + navItems, + activeSection, + currentProject, + onNavigate, +}: SettingsNavigationProps) { + return ( + + ); +} diff --git a/app/src/components/views/settings-view/config/navigation.ts b/app/src/components/views/settings-view/config/navigation.ts new file mode 100644 index 00000000..c6f5432a --- /dev/null +++ b/app/src/components/views/settings-view/config/navigation.ts @@ -0,0 +1,29 @@ +import type { LucideIcon } from "lucide-react"; +import { + Key, + Terminal, + Atom, + Palette, + LayoutGrid, + Settings2, + FlaskConical, + Trash2, +} from "lucide-react"; + +export interface NavigationItem { + id: string; + label: string; + icon: LucideIcon; +} + +// Navigation items for the settings side panel +export const NAV_ITEMS: NavigationItem[] = [ + { id: "api-keys", label: "API Keys", icon: Key }, + { id: "claude", label: "Claude", icon: Terminal }, + { id: "codex", label: "Codex", icon: Atom }, + { id: "appearance", label: "Appearance", icon: Palette }, + { id: "kanban", label: "Kanban Display", icon: LayoutGrid }, + { id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 }, + { id: "defaults", label: "Feature Defaults", icon: FlaskConical }, + { id: "danger", label: "Danger Zone", icon: Trash2 }, +]; diff --git a/app/src/components/views/settings-view/danger-zone/danger-zone-section.tsx b/app/src/components/views/settings-view/danger-zone/danger-zone-section.tsx new file mode 100644 index 00000000..f6bec6a8 --- /dev/null +++ b/app/src/components/views/settings-view/danger-zone/danger-zone-section.tsx @@ -0,0 +1,57 @@ +import { Button } from "@/components/ui/button"; +import { Trash2, Folder } from "lucide-react"; +import type { Project } from "../shared/types"; + +interface DangerZoneSectionProps { + project: Project | null; + onDeleteClick: () => void; +} + +export function DangerZoneSection({ + project, + onDeleteClick, +}: DangerZoneSectionProps) { + if (!project) return null; + + return ( +
+
+
+ +

Danger Zone

+
+

+ Permanently remove this project from Automaker. +

+
+
+
+
+
+ +
+
+

+ {project.name} +

+

+ {project.path} +

+
+
+ +
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx new file mode 100644 index 00000000..e7c78582 --- /dev/null +++ b/app/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -0,0 +1,134 @@ +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react"; + +interface FeatureDefaultsSectionProps { + showProfilesOnly: boolean; + defaultSkipTests: boolean; + useWorktrees: boolean; + onShowProfilesOnlyChange: (value: boolean) => void; + onDefaultSkipTestsChange: (value: boolean) => void; + onUseWorktreesChange: (value: boolean) => void; +} + +export function FeatureDefaultsSection({ + showProfilesOnly, + defaultSkipTests, + useWorktrees, + onShowProfilesOnlyChange, + onDefaultSkipTestsChange, + onUseWorktreesChange, +}: FeatureDefaultsSectionProps) { + return ( +
+
+
+ +

+ Feature Defaults +

+
+

+ Configure default settings for new features. +

+
+
+ {/* Profiles Only Setting */} +
+
+ + onShowProfilesOnlyChange(checked === true) + } + className="mt-0.5" + data-testid="show-profiles-only-checkbox" + /> +
+ +

+ When enabled, the Add Feature dialog will show only AI profiles + and hide advanced model tweaking options (Claude SDK, thinking + levels, and OpenAI Codex CLI). This creates a cleaner, less + overwhelming UI. You can always disable this to access advanced + settings. +

+
+
+
+ + {/* Separator */} +
+ + {/* Skip Tests Setting */} +
+
+ + onDefaultSkipTestsChange(checked === true) + } + className="mt-0.5" + data-testid="default-skip-tests-checkbox" + /> +
+ +

+ When enabled, new features will default to manual verification + instead of TDD (test-driven development). You can still override + this for individual features. +

+
+
+
+ + {/* Worktree Isolation Setting */} +
+
+ + onUseWorktreesChange(checked === true) + } + className="mt-0.5" + data-testid="use-worktrees-checkbox" + /> +
+ +

+ Creates isolated git branches for each feature. When disabled, + agents work directly in the main project directory. This feature + is experimental and may require additional setup like branch + selection and merge configuration. +

+
+
+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/hooks/use-cli-status.ts b/app/src/components/views/settings-view/hooks/use-cli-status.ts new file mode 100644 index 00000000..078acdbe --- /dev/null +++ b/app/src/components/views/settings-view/hooks/use-cli-status.ts @@ -0,0 +1,169 @@ +import { useState, useEffect, useCallback } from "react"; +import { useSetupStore } from "@/store/setup-store"; +import { getElectronAPI } from "@/lib/electron"; + +interface CliStatusResult { + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + recommendation?: string; + installCommands?: { + macos?: string; + windows?: string; + linux?: string; + npm?: string; + }; + error?: string; +} + +interface CodexCliStatusResult extends CliStatusResult { + hasApiKey?: boolean; +} + +/** + * Custom hook for managing Claude and Codex CLI status + * Handles checking CLI installation, authentication, and refresh functionality + */ +export function useCliStatus() { + const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore(); + + const [claudeCliStatus, setClaudeCliStatus] = + useState(null); + + const [codexCliStatus, setCodexCliStatus] = + useState(null); + + const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); + const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); + + // Check CLI status on mount + useEffect(() => { + const checkCliStatus = async () => { + const api = getElectronAPI(); + + // Check Claude CLI + if (api?.checkClaudeCli) { + try { + const status = await api.checkClaudeCli(); + setClaudeCliStatus(status); + } catch (error) { + console.error("Failed to check Claude CLI status:", error); + } + } + + // Check Codex CLI + if (api?.checkCodexCli) { + try { + const status = await api.checkCodexCli(); + setCodexCliStatus(status); + } catch (error) { + console.error("Failed to check Codex CLI status:", error); + } + } + + // Check Claude auth status (re-fetch on mount to ensure persistence) + if (api?.setup?.getClaudeStatus) { + try { + const result = await api.setup.getClaudeStatus(); + if (result.success && result.auth) { + const auth = result.auth; + const authStatus = { + authenticated: auth.authenticated, + method: + auth.method === "oauth_token" + ? ("oauth" as const) + : auth.method?.includes("api_key") + ? ("api_key" as const) + : ("none" as const), + hasCredentialsFile: auth.hasCredentialsFile ?? false, + oauthTokenValid: auth.hasStoredOAuthToken, + apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey, + }; + setClaudeAuthStatus(authStatus); + } + } catch (error) { + console.error("Failed to check Claude auth status:", error); + } + } + + // Check Codex auth status (re-fetch on mount to ensure persistence) + if (api?.setup?.getCodexStatus) { + try { + const result = await api.setup.getCodexStatus(); + if (result.success && result.auth) { + const auth = result.auth; + // Determine method - prioritize cli_verified and cli_tokens over auth_file + const method = + auth.method === "cli_verified" || auth.method === "cli_tokens" + ? auth.method === "cli_verified" + ? ("cli_verified" as const) + : ("cli_tokens" as const) + : auth.method === "auth_file" + ? ("api_key" as const) + : auth.method === "env_var" + ? ("env" as const) + : ("none" as const); + + const authStatus = { + authenticated: auth.authenticated, + method, + // Only set apiKeyValid for actual API key methods, not CLI login + apiKeyValid: + method === "cli_verified" || method === "cli_tokens" + ? undefined + : auth.hasAuthFile || auth.hasEnvKey, + }; + setCodexAuthStatus(authStatus); + } + } catch (error) { + console.error("Failed to check Codex auth status:", error); + } + } + }; + + checkCliStatus(); + }, [setClaudeAuthStatus, setCodexAuthStatus]); + + // Refresh Claude CLI status + const handleRefreshClaudeCli = useCallback(async () => { + setIsCheckingClaudeCli(true); + try { + const api = getElectronAPI(); + if (api?.checkClaudeCli) { + const status = await api.checkClaudeCli(); + setClaudeCliStatus(status); + } + } catch (error) { + console.error("Failed to refresh Claude CLI status:", error); + } finally { + setIsCheckingClaudeCli(false); + } + }, []); + + // Refresh Codex CLI status + const handleRefreshCodexCli = useCallback(async () => { + setIsCheckingCodexCli(true); + try { + const api = getElectronAPI(); + if (api?.checkCodexCli) { + const status = await api.checkCodexCli(); + setCodexCliStatus(status); + } + } catch (error) { + console.error("Failed to refresh Codex CLI status:", error); + } finally { + setIsCheckingCodexCli(false); + } + }, []); + + return { + claudeCliStatus, + codexCliStatus, + isCheckingClaudeCli, + isCheckingCodexCli, + handleRefreshClaudeCli, + handleRefreshCodexCli, + }; +} diff --git a/app/src/components/views/settings-view/kanban-display/kanban-display-section.tsx b/app/src/components/views/settings-view/kanban-display/kanban-display-section.tsx new file mode 100644 index 00000000..d371966e --- /dev/null +++ b/app/src/components/views/settings-view/kanban-display/kanban-display-section.tsx @@ -0,0 +1,96 @@ +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { LayoutGrid, Minimize2, Square, Maximize2 } from "lucide-react"; +import type { KanbanDetailLevel } from "../shared/types"; + +interface KanbanDisplaySectionProps { + detailLevel: KanbanDetailLevel; + onChange: (level: KanbanDetailLevel) => void; +} + +export function KanbanDisplaySection({ + detailLevel, + onChange, +}: KanbanDisplaySectionProps) { + return ( +
+
+
+ +

+ Kanban Card Display +

+
+

+ Control how much information is displayed on Kanban cards. +

+
+
+
+ +
+ + + +
+

+ Minimal: Shows only title and category +
+ Standard: Adds steps preview and progress bar +
+ Detailed: Shows all info including model, tool + calls, task list, and summaries +

+
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/keyboard-shortcuts/keyboard-shortcuts-section.tsx b/app/src/components/views/settings-view/keyboard-shortcuts/keyboard-shortcuts-section.tsx new file mode 100644 index 00000000..a1fa5e34 --- /dev/null +++ b/app/src/components/views/settings-view/keyboard-shortcuts/keyboard-shortcuts-section.tsx @@ -0,0 +1,59 @@ +import { Button } from "@/components/ui/button"; +import { Settings2, Keyboard } from "lucide-react"; + +interface KeyboardShortcutsSectionProps { + onOpenKeyboardMap: () => void; +} + +export function KeyboardShortcutsSection({ + onOpenKeyboardMap, +}: KeyboardShortcutsSectionProps) { + return ( +
+
+
+ +

+ Keyboard Shortcuts +

+
+

+ Customize keyboard shortcuts for navigation and actions using the + visual keyboard map. +

+
+
+ {/* Centered message directing to keyboard map */} +
+
+ +
+
+
+

+ Use the Visual Keyboard Map +

+

+ Click the "View Keyboard Map" button above to customize + your keyboard shortcuts. The visual interface shows all available + keys and lets you easily edit shortcuts with single-modifier + restrictions. +

+
+ +
+
+
+ ); +} diff --git a/app/src/components/views/settings-view/shared/types.ts b/app/src/components/views/settings-view/shared/types.ts new file mode 100644 index 00000000..e28966a6 --- /dev/null +++ b/app/src/components/views/settings-view/shared/types.ts @@ -0,0 +1,47 @@ +// Shared TypeScript types for settings view components + +export interface CliStatus { + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + hasApiKey?: boolean; + recommendation?: string; + installCommands?: { + macos?: string; + windows?: string; + linux?: string; + npm?: string; + }; + error?: string; +} + +export type Theme = + | "dark" + | "light" + | "retro" + | "dracula" + | "nord" + | "monokai" + | "tokyonight" + | "solarized" + | "gruvbox" + | "catppuccin" + | "onedark" + | "synthwave"; + +export type KanbanDetailLevel = "minimal" | "standard" | "detailed"; + +export interface Project { + id: string; + name: string; + path: string; + theme?: Theme; +} + +export interface ApiKeys { + anthropic: string; + google: string; + openai: string; +} diff --git a/app/src/components/views/setup-view.tsx b/app/src/components/views/setup-view.tsx index 229d955c..cac5e1f1 100644 --- a/app/src/components/views/setup-view.tsx +++ b/app/src/components/views/setup-view.tsx @@ -11,7 +11,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { useSetupStore } from "@/store/setup-store"; +import { useSetupStore, type CodexAuthStatus } from "@/store/setup-store"; import { useAppStore } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; import { @@ -780,6 +780,22 @@ function CodexSetupStep({ const [apiKey, setApiKey] = useState(""); const [isSavingKey, setIsSavingKey] = useState(false); + // Normalize CLI auth method strings to our store-friendly values + const mapAuthMethod = (method?: string): CodexAuthStatus["method"] => { + switch (method) { + case "cli_verified": + return "cli_verified"; + case "cli_tokens": + return "cli_tokens"; + case "auth_file": + return "api_key"; + case "env_var": + return "env"; + default: + return "none"; + } + }; + const checkStatus = useCallback(async () => { console.log("[Codex Setup] Starting status check..."); setIsChecking(true); @@ -805,13 +821,16 @@ function CodexSetupStep({ setCodexCliStatus(cliStatus); if (result.auth) { - const authStatus = { + const method = mapAuthMethod(result.auth.method); + + const authStatus: CodexAuthStatus = { authenticated: result.auth.authenticated, - method: result.auth.method === "auth_file" ? "api_key" : result.auth.method === "env_var" ? "env" : "none", - apiKeyValid: result.auth.authenticated, + method, + // Only set apiKeyValid for actual API key methods, not CLI login + apiKeyValid: method === "cli_verified" || method === "cli_tokens" ? undefined : result.auth.authenticated, }; console.log("[Codex Setup] Auth Status:", authStatus); - setCodexAuthStatus(authStatus as any); + setCodexAuthStatus(authStatus); } else { console.log("[Codex Setup] No auth info in result"); } diff --git a/app/src/config/api-providers.ts b/app/src/config/api-providers.ts new file mode 100644 index 00000000..b0a9bf78 --- /dev/null +++ b/app/src/config/api-providers.ts @@ -0,0 +1,149 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { LucideIcon } from "lucide-react"; +import type { ApiKeys } from "@/store/app-store"; + +export type ProviderKey = "anthropic" | "google" | "openai"; + +export interface ProviderConfig { + key: ProviderKey; + label: string; + inputId: string; + placeholder: string; + value: string; + setValue: Dispatch>; + showValue: boolean; + setShowValue: Dispatch>; + hasStoredKey: string | null | undefined; + inputTestId: string; + toggleTestId: string; + testButton: { + onClick: () => Promise | void; + disabled: boolean; + loading: boolean; + testId: string; + }; + result: { success: boolean; message: string } | null; + resultTestId: string; + resultMessageTestId: string; + descriptionPrefix: string; + descriptionLinkHref: string; + descriptionLinkText: string; + descriptionSuffix?: string; +} + +export interface ProviderConfigParams { + apiKeys: ApiKeys; + anthropic: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; + google: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; + openai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; +} + +export const buildProviderConfigs = ({ + apiKeys, + anthropic, + google, + openai, +}: ProviderConfigParams): ProviderConfig[] => [ + { + key: "anthropic", + label: "Anthropic API Key (Claude)", + inputId: "anthropic-key", + placeholder: "sk-ant-...", + value: anthropic.value, + setValue: anthropic.setValue, + showValue: anthropic.show, + setShowValue: anthropic.setShow, + hasStoredKey: apiKeys.anthropic, + inputTestId: "anthropic-api-key-input", + toggleTestId: "toggle-anthropic-visibility", + testButton: { + onClick: anthropic.onTest, + disabled: !anthropic.value || anthropic.testing, + loading: anthropic.testing, + testId: "test-claude-connection", + }, + result: anthropic.result, + resultTestId: "test-connection-result", + resultMessageTestId: "test-connection-message", + descriptionPrefix: "Used for Claude AI features. Get your key at", + descriptionLinkHref: "https://console.anthropic.com/account/keys", + descriptionLinkText: "console.anthropic.com", + descriptionSuffix: + ". Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment variable can be used.", + }, + { + key: "google", + label: "Google API Key (Gemini)", + inputId: "google-key", + placeholder: "AIza...", + value: google.value, + setValue: google.setValue, + showValue: google.show, + setShowValue: google.setShow, + hasStoredKey: apiKeys.google, + inputTestId: "google-api-key-input", + toggleTestId: "toggle-google-visibility", + testButton: { + onClick: google.onTest, + disabled: !google.value || google.testing, + loading: google.testing, + testId: "test-gemini-connection", + }, + result: google.result, + resultTestId: "gemini-test-connection-result", + resultMessageTestId: "gemini-test-connection-message", + descriptionPrefix: + "Used for Gemini AI features (including image/design prompts). Get your key at", + descriptionLinkHref: "https://makersuite.google.com/app/apikey", + descriptionLinkText: "makersuite.google.com", + }, + { + key: "openai", + label: "OpenAI API Key (Codex/GPT)", + inputId: "openai-key", + placeholder: "sk-...", + value: openai.value, + setValue: openai.setValue, + showValue: openai.show, + setShowValue: openai.setShow, + hasStoredKey: apiKeys.openai, + inputTestId: "openai-api-key-input", + toggleTestId: "toggle-openai-visibility", + testButton: { + onClick: openai.onTest, + disabled: !openai.value || openai.testing, + loading: openai.testing, + testId: "test-openai-connection", + }, + result: openai.result, + resultTestId: "openai-test-connection-result", + resultMessageTestId: "openai-test-connection-message", + descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at", + descriptionLinkHref: "https://platform.openai.com/api-keys", + descriptionLinkText: "platform.openai.com", + }, +]; diff --git a/app/src/config/theme-options.ts b/app/src/config/theme-options.ts new file mode 100644 index 00000000..ac8bc567 --- /dev/null +++ b/app/src/config/theme-options.ts @@ -0,0 +1,88 @@ +import { + type LucideIcon, + Atom, + Cat, + Eclipse, + Flame, + Ghost, + Moon, + Radio, + Snowflake, + Sparkles, + Sun, + Terminal, + Trees, +} from "lucide-react"; +import { Theme } from "@/components/views/settings-view/shared/types"; + +export interface ThemeOption { + value: Theme; + label: string; + Icon: LucideIcon; + testId: string; +} + +export const themeOptions: ReadonlyArray = [ + { value: "dark", label: "Dark", Icon: Moon, testId: "dark-mode-button" }, + { value: "light", label: "Light", Icon: Sun, testId: "light-mode-button" }, + { + value: "retro", + label: "Retro", + Icon: Terminal, + testId: "retro-mode-button", + }, + { + value: "dracula", + label: "Dracula", + Icon: Ghost, + testId: "dracula-mode-button", + }, + { + value: "nord", + label: "Nord", + Icon: Snowflake, + testId: "nord-mode-button", + }, + { + value: "monokai", + label: "Monokai", + Icon: Flame, + testId: "monokai-mode-button", + }, + { + value: "tokyonight", + label: "Tokyo Night", + Icon: Sparkles, + testId: "tokyonight-mode-button", + }, + { + value: "solarized", + label: "Solarized", + Icon: Eclipse, + testId: "solarized-mode-button", + }, + { + value: "gruvbox", + label: "Gruvbox", + Icon: Trees, + testId: "gruvbox-mode-button", + }, + { + value: "catppuccin", + label: "Catppuccin", + Icon: Cat, + testId: "catppuccin-mode-button", + }, + { + value: "onedark", + label: "One Dark", + Icon: Atom, + testId: "onedark-mode-button", + }, + { + value: "synthwave", + label: "Synthwave", + Icon: Radio, + testId: "synthwave-mode-button", + }, +]; diff --git a/app/src/hooks/use-keyboard-shortcuts.ts b/app/src/hooks/use-keyboard-shortcuts.ts index a9b901ff..8e12c2f5 100644 --- a/app/src/hooks/use-keyboard-shortcuts.ts +++ b/app/src/hooks/use-keyboard-shortcuts.ts @@ -1,10 +1,10 @@ "use client"; import { useEffect, useCallback } from "react"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, parseShortcut } from "@/store/app-store"; export interface KeyboardShortcut { - key: string; + key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K" action: () => void; description?: string; } @@ -59,9 +59,44 @@ function isInputFocused(): boolean { return false; } +/** + * Check if a keyboard event matches a shortcut definition + */ +function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean { + const shortcut = parseShortcut(shortcutStr); + + // Check if the key matches (case-insensitive) + if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) { + return false; + } + + // Check modifier keys + const cmdCtrlPressed = event.metaKey || event.ctrlKey; + const shiftPressed = event.shiftKey; + const altPressed = event.altKey; + + // If shortcut requires cmdCtrl, it must be pressed + if (shortcut.cmdCtrl && !cmdCtrlPressed) return false; + // If shortcut doesn't require cmdCtrl, it shouldn't be pressed + if (!shortcut.cmdCtrl && cmdCtrlPressed) return false; + + // If shortcut requires shift, it must be pressed + if (shortcut.shift && !shiftPressed) return false; + // If shortcut doesn't require shift, it shouldn't be pressed + if (!shortcut.shift && shiftPressed) return false; + + // If shortcut requires alt, it must be pressed + if (shortcut.alt && !altPressed) return false; + // If shortcut doesn't require alt, it shouldn't be pressed + if (!shortcut.alt && altPressed) return false; + + return true; +} + /** * Hook to manage keyboard shortcuts * Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open + * Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option */ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { const handleKeyDown = useCallback( @@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) { return; } - // Don't trigger if any modifier keys are pressed (except for specific combos we want) - if (event.ctrlKey || event.altKey || event.metaKey) { - return; - } - // Find matching shortcut const matchingShortcut = shortcuts.find( - (shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase() + (shortcut) => matchesShortcut(event, shortcut.key) ); if (matchingShortcut) { diff --git a/app/src/hooks/use-scroll-tracking.ts b/app/src/hooks/use-scroll-tracking.ts new file mode 100644 index 00000000..25f86a13 --- /dev/null +++ b/app/src/hooks/use-scroll-tracking.ts @@ -0,0 +1,104 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +interface ScrollTrackingItem { + id: string; +} + +interface UseScrollTrackingOptions { + /** Navigation items with at least an id property */ + items: T[]; + /** Optional filter function to determine which items should be tracked */ + filterFn?: (item: T) => boolean; + /** Optional initial active section (defaults to first item's id) */ + initialSection?: string; + /** Optional offset from top when scrolling to section (defaults to 24) */ + scrollOffset?: number; +} + +/** + * Generic custom hook for managing scroll-based navigation tracking + * Automatically highlights the active section based on scroll position + * and provides smooth scrolling to sections + */ +export function useScrollTracking({ + items, + filterFn = () => true, + initialSection, + scrollOffset = 24, +}: UseScrollTrackingOptions) { + const [activeSection, setActiveSection] = useState( + initialSection || items[0]?.id || "" + ); + const scrollContainerRef = useRef(null); + + // Track scroll position to highlight active nav item + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleScroll = () => { + const sections = items + .filter(filterFn) + .map((item) => ({ + id: item.id, + element: document.getElementById(item.id), + })) + .filter((s) => s.element); + + const containerRect = container.getBoundingClientRect(); + const scrollTop = container.scrollTop; + const scrollHeight = container.scrollHeight; + const clientHeight = container.clientHeight; + + // Check if scrolled to bottom (within a small threshold) + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; + + if (isAtBottom && sections.length > 0) { + // If at bottom, highlight the last visible section + setActiveSection(sections[sections.length - 1].id); + return; + } + + for (let i = sections.length - 1; i >= 0; i--) { + const section = sections[i]; + if (section.element) { + const rect = section.element.getBoundingClientRect(); + const relativeTop = rect.top - containerRect.top + scrollTop; + if (scrollTop >= relativeTop - 100) { + setActiveSection(section.id); + break; + } + } + } + }; + + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + }, [items, filterFn]); + + // Scroll to a specific section with smooth animation + const scrollToSection = useCallback( + (sectionId: string) => { + const element = document.getElementById(sectionId); + if (element && scrollContainerRef.current) { + const container = scrollContainerRef.current; + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const relativeTop = + elementRect.top - containerRect.top + container.scrollTop; + + container.scrollTo({ + top: relativeTop - scrollOffset, + behavior: "smooth", + }); + } + }, + [scrollOffset] + ); + + return { + activeSection, + scrollToSection, + scrollContainerRef, + }; +} diff --git a/app/src/hooks/use-window-state.ts b/app/src/hooks/use-window-state.ts index 8a4bc332..a54bafd7 100644 --- a/app/src/hooks/use-window-state.ts +++ b/app/src/hooks/use-window-state.ts @@ -52,3 +52,5 @@ export function useWindowState(): WindowState { return windowState; } + + diff --git a/app/src/lib/electron.ts b/app/src/lib/electron.ts index 4ea65497..43ae6e9a 100644 --- a/app/src/lib/electron.ts +++ b/app/src/lib/electron.ts @@ -316,6 +316,9 @@ export interface ElectronAPI { method: string; hasCredentialsFile: boolean; hasToken: boolean; + hasStoredOAuthToken?: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; }; error?: string; }>; @@ -327,9 +330,11 @@ export interface ElectronAPI { path?: string; auth?: { authenticated: boolean; - method: string; + method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none" hasAuthFile: boolean; hasEnvKey: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; }; error?: string; }>; @@ -723,6 +728,9 @@ interface SetupAPI { method: string; hasCredentialsFile: boolean; hasToken: boolean; + hasStoredOAuthToken?: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; }; error?: string; }>; @@ -734,9 +742,11 @@ interface SetupAPI { path?: string; auth?: { authenticated: boolean; - method: string; + method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none" hasAuthFile: boolean; hasEnvKey: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; }; error?: string; }>; diff --git a/app/src/store/app-store.ts b/app/src/store/app-store.ts index 1f1ceb90..d5a1b9c9 100644 --- a/app/src/store/app-store.ts +++ b/app/src/store/app-store.ts @@ -38,7 +38,75 @@ export interface ApiKeys { openai: string; } -// Keyboard Shortcuts +// Keyboard Shortcut with optional modifiers +export interface ShortcutKey { + key: string; // The main key (e.g., "K", "N", "1") + shift?: boolean; // Shift key modifier + cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux + alt?: boolean; // Alt/Option key modifier +} + +// Helper to parse shortcut string to ShortcutKey object +export function parseShortcut(shortcut: string): ShortcutKey { + const parts = shortcut.split("+").map(p => p.trim()); + const result: ShortcutKey = { key: parts[parts.length - 1] }; + + // Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl + for (let i = 0; i < parts.length - 1; i++) { + const modifier = parts[i].toLowerCase(); + if (modifier === "shift") result.shift = true; + else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true; + else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true; + } + + return result; +} + +// Helper to format ShortcutKey to display string +export function formatShortcut(shortcut: string, forDisplay = false): string { + const parsed = parseShortcut(shortcut); + const parts: string[] = []; + + // Prefer User-Agent Client Hints when available; fall back to legacy + const platform: 'darwin' | 'win32' | 'linux' = (() => { + if (typeof navigator === 'undefined') return 'linux'; + + const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform?.toLowerCase?.(); + const legacyPlatform = navigator.platform?.toLowerCase?.(); + const platformString = uaPlatform || legacyPlatform || ''; + + if (platformString.includes('mac')) return 'darwin'; + if (platformString.includes('win')) return 'win32'; + return 'linux'; + })(); + + // Primary modifier - OS-specific + if (parsed.cmdCtrl) { + if (forDisplay) { + parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆'); + } else { + parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super'); + } + } + + // Alt/Option + if (parsed.alt) { + parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt')); + } + + // Shift + if (parsed.shift) { + parts.push(forDisplay ? '⇧' : 'Shift'); + } + + parts.push(parsed.key.toUpperCase()); + + // Add spacing when displaying symbols + return parts.join(forDisplay ? " " : "+"); +} + +// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K" export interface KeyboardShortcuts { // Navigation shortcuts board: string; @@ -48,10 +116,10 @@ export interface KeyboardShortcuts { tools: string; settings: string; profiles: string; - + // UI shortcuts toggleSidebar: string; - + // Action shortcuts addFeature: string; addContextFile: string; diff --git a/app/src/store/setup-store.ts b/app/src/store/setup-store.ts index 6d4ded1d..8741ba69 100644 --- a/app/src/store/setup-store.ts +++ b/app/src/store/setup-store.ts @@ -23,7 +23,7 @@ export interface ClaudeAuthStatus { // Codex Auth Status export interface CodexAuthStatus { authenticated: boolean; - method: "api_key" | "env" | "none"; + method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none"; apiKeyValid?: boolean; mcpConfigured?: boolean; error?: string;