mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge branch 'main' into running-agents-list
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
const { execSync, spawn } = require('child_process');
|
const { execSync, spawn } = require("child_process");
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const os = require('os');
|
const os = require("os");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude CLI Detector
|
* Claude CLI Detector
|
||||||
@@ -21,41 +21,47 @@ class ClaudeCliDetector {
|
|||||||
*/
|
*/
|
||||||
static getUpdatedPathFromShellConfig() {
|
static getUpdatedPathFromShellConfig() {
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || "/bin/bash";
|
||||||
const shellName = path.basename(shell);
|
const shellName = path.basename(shell);
|
||||||
|
|
||||||
// Common shell config files
|
// Common shell config files
|
||||||
const configFiles = [];
|
const configFiles = [];
|
||||||
if (shellName.includes('zsh')) {
|
if (shellName.includes("zsh")) {
|
||||||
configFiles.push(path.join(homeDir, '.zshrc'));
|
configFiles.push(path.join(homeDir, ".zshrc"));
|
||||||
configFiles.push(path.join(homeDir, '.zshenv'));
|
configFiles.push(path.join(homeDir, ".zshenv"));
|
||||||
configFiles.push(path.join(homeDir, '.zprofile'));
|
configFiles.push(path.join(homeDir, ".zprofile"));
|
||||||
} else if (shellName.includes('bash')) {
|
} else if (shellName.includes("bash")) {
|
||||||
configFiles.push(path.join(homeDir, '.bashrc'));
|
configFiles.push(path.join(homeDir, ".bashrc"));
|
||||||
configFiles.push(path.join(homeDir, '.bash_profile'));
|
configFiles.push(path.join(homeDir, ".bash_profile"));
|
||||||
configFiles.push(path.join(homeDir, '.profile'));
|
configFiles.push(path.join(homeDir, ".profile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check common locations
|
// Also check common locations
|
||||||
const commonPaths = [
|
const commonPaths = [
|
||||||
path.join(homeDir, '.local', 'bin'),
|
path.join(homeDir, ".local", "bin"),
|
||||||
path.join(homeDir, '.cargo', 'bin'),
|
path.join(homeDir, ".cargo", "bin"),
|
||||||
'/usr/local/bin',
|
"/usr/local/bin",
|
||||||
'/opt/homebrew/bin',
|
"/opt/homebrew/bin",
|
||||||
path.join(homeDir, 'bin'),
|
path.join(homeDir, "bin"),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Try to extract PATH additions from config files
|
// Try to extract PATH additions from config files
|
||||||
for (const configFile of configFiles) {
|
for (const configFile of configFiles) {
|
||||||
if (fs.existsSync(configFile)) {
|
if (fs.existsSync(configFile)) {
|
||||||
try {
|
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
|
// 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) {
|
if (pathMatches) {
|
||||||
for (const match of pathMatches) {
|
for (const match of pathMatches) {
|
||||||
const pathValue = match.replace(/export\s+PATH=["']?/, '').replace(/["']?$/, '');
|
const pathValue = match
|
||||||
const paths = pathValue.split(':').filter(p => p && !p.includes('$'));
|
.replace(/export\s+PATH=["']?/, "")
|
||||||
|
.replace(/["']?$/, "");
|
||||||
|
const paths = pathValue
|
||||||
|
.split(":")
|
||||||
|
.filter((p) => p && !p.includes("$"));
|
||||||
commonPaths.push(...paths);
|
commonPaths.push(...paths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,21 +75,28 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static detectClaudeInstallation() {
|
static detectClaudeInstallation() {
|
||||||
console.log('[ClaudeCliDetector] Detecting Claude installation...');
|
console.log("[ClaudeCliDetector] Detecting Claude installation...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Method 1: Check if 'claude' command is in PATH (Unix)
|
// Method 1: Check if 'claude' command is in PATH (Unix)
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
try {
|
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) {
|
if (claudePath) {
|
||||||
const version = this.getClaudeVersion(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 {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,17 +105,26 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Method 2: Check Windows path
|
// Method 2: Check Windows path
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === "win32") {
|
||||||
try {
|
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) {
|
if (claudePath) {
|
||||||
const version = this.getClaudeVersion(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 {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,21 +133,31 @@ class ClaudeCliDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Method 3: Check for local installation
|
// 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)) {
|
if (fs.existsSync(localClaudePath)) {
|
||||||
const version = this.getClaudeVersion(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 {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: localClaudePath,
|
path: localClaudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli-local'
|
method: "cli-local",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 4: Check common installation locations (including those from shell config)
|
// Method 4: Check common installation locations (including those from shell config)
|
||||||
const commonPaths = this.getUpdatedPathFromShellConfig();
|
const commonPaths = this.getUpdatedPathFromShellConfig();
|
||||||
const binaryNames = ['claude', 'claude-code'];
|
const binaryNames = ["claude", "claude-code"];
|
||||||
|
|
||||||
for (const basePath of commonPaths) {
|
for (const basePath of commonPaths) {
|
||||||
for (const binaryName of binaryNames) {
|
for (const binaryName of binaryNames) {
|
||||||
@@ -133,12 +165,17 @@ class ClaudeCliDetector {
|
|||||||
if (fs.existsSync(claudePath)) {
|
if (fs.existsSync(claudePath)) {
|
||||||
try {
|
try {
|
||||||
const version = this.getClaudeVersion(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 {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
version: version,
|
||||||
method: 'cli'
|
method: "cli",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File exists but can't get version, might not be executable
|
// 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)
|
// Method 5: Try to source shell config and check PATH again (for Unix)
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || "/bin/bash";
|
||||||
const shellName = path.basename(shell);
|
const shellName = path.basename(shell);
|
||||||
const homeDir = os.homedir();
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
let sourceCmd = '';
|
let sourceCmd = "";
|
||||||
if (shellName.includes('zsh')) {
|
if (shellName.includes("zsh")) {
|
||||||
sourceCmd = `source ${homeDir}/.zshrc 2>/dev/null && which claude`;
|
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`;
|
sourceCmd = `source ${homeDir}/.bashrc 2>/dev/null && which claude`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceCmd) {
|
if (sourceCmd) {
|
||||||
const claudePath = execSync(`bash -c "${sourceCmd}"`, { encoding: 'utf-8', timeout: 2000 }).trim();
|
const claudePath = execSync(`bash -c "${sourceCmd}"`, {
|
||||||
if (claudePath && claudePath.startsWith('/')) {
|
encoding: "utf-8",
|
||||||
|
timeout: 2000,
|
||||||
|
}).trim();
|
||||||
|
if (claudePath && claudePath.startsWith("/")) {
|
||||||
const version = this.getClaudeVersion(claudePath);
|
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 {
|
return {
|
||||||
installed: true,
|
installed: true,
|
||||||
path: claudePath,
|
path: claudePath,
|
||||||
version: version,
|
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 {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
path: null,
|
path: null,
|
||||||
version: null,
|
version: null,
|
||||||
method: 'none'
|
method: "none",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Error detecting Claude installation:",
|
||||||
|
error
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
installed: false,
|
installed: false,
|
||||||
path: null,
|
path: null,
|
||||||
version: null,
|
version: null,
|
||||||
method: 'none',
|
method: "none",
|
||||||
error: error.message
|
error: error.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,8 +254,8 @@ class ClaudeCliDetector {
|
|||||||
static getClaudeVersion(claudePath) {
|
static getClaudeVersion(claudePath) {
|
||||||
try {
|
try {
|
||||||
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
|
const version = execSync(`"${claudePath}" --version 2>/dev/null`, {
|
||||||
encoding: 'utf-8',
|
encoding: "utf-8",
|
||||||
timeout: 5000
|
timeout: 5000,
|
||||||
}).trim();
|
}).trim();
|
||||||
return version || null;
|
return version || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -226,10 +274,10 @@ class ClaudeCliDetector {
|
|||||||
* @returns {Object} Authentication status
|
* @returns {Object} Authentication status
|
||||||
*/
|
*/
|
||||||
static getAuthStatus(appCredentialsPath) {
|
static getAuthStatus(appCredentialsPath) {
|
||||||
console.log('[ClaudeCliDetector] Checking auth status...');
|
console.log("[ClaudeCliDetector] Checking auth status...");
|
||||||
|
|
||||||
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
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
|
// Check app's stored credentials
|
||||||
let storedOAuthToken = null;
|
let storedOAuthToken = null;
|
||||||
@@ -237,38 +285,44 @@ class ClaudeCliDetector {
|
|||||||
|
|
||||||
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
|
if (appCredentialsPath && fs.existsSync(appCredentialsPath)) {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(appCredentialsPath, 'utf-8');
|
const content = fs.readFileSync(appCredentialsPath, "utf-8");
|
||||||
const credentials = JSON.parse(content);
|
const credentials = JSON.parse(content);
|
||||||
storedOAuthToken = credentials.anthropic_oauth_token || null;
|
storedOAuthToken = credentials.anthropic_oauth_token || null;
|
||||||
storedApiKey = credentials.anthropic || credentials.anthropic_api_key || null;
|
storedApiKey =
|
||||||
console.log('[ClaudeCliDetector] App credentials:', {
|
credentials.anthropic || credentials.anthropic_api_key || null;
|
||||||
|
console.log("[ClaudeCliDetector] App credentials:", {
|
||||||
hasOAuthToken: !!storedOAuthToken,
|
hasOAuthToken: !!storedOAuthToken,
|
||||||
hasApiKey: !!storedApiKey
|
hasApiKey: !!storedApiKey,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClaudeCliDetector] Error reading app credentials:', error);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Error reading app credentials:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine authentication method
|
// Determine authentication method
|
||||||
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
// Priority: Stored OAuth Token > Stored API Key > Env API Key
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let method = 'none';
|
let method = "none";
|
||||||
|
|
||||||
if (storedOAuthToken) {
|
if (storedOAuthToken) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'oauth_token';
|
method = "oauth_token";
|
||||||
console.log('[ClaudeCliDetector] Using stored OAuth token (subscription)');
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Using stored OAuth token (subscription)"
|
||||||
|
);
|
||||||
} else if (storedApiKey) {
|
} else if (storedApiKey) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'api_key';
|
method = "api_key";
|
||||||
console.log('[ClaudeCliDetector] Using stored API key');
|
console.log("[ClaudeCliDetector] Using stored API key");
|
||||||
} else if (envApiKey) {
|
} else if (envApiKey) {
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
method = 'api_key_env';
|
method = "api_key_env";
|
||||||
console.log('[ClaudeCliDetector] Using environment API key');
|
console.log("[ClaudeCliDetector] Using environment API key");
|
||||||
} else {
|
} else {
|
||||||
console.log('[ClaudeCliDetector] No authentication found');
|
console.log("[ClaudeCliDetector] No authentication found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -276,12 +330,26 @@ class ClaudeCliDetector {
|
|||||||
method,
|
method,
|
||||||
hasStoredOAuthToken: !!storedOAuthToken,
|
hasStoredOAuthToken: !!storedOAuthToken,
|
||||||
hasStoredApiKey: !!storedApiKey,
|
hasStoredApiKey: !!storedApiKey,
|
||||||
hasEnvApiKey: !!envApiKey
|
hasEnvApiKey: !!envApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[ClaudeCliDetector] Auth status result:', result);
|
console.log("[ClaudeCliDetector] Auth status result:", result);
|
||||||
return 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
|
* Get full status including installation and auth
|
||||||
@@ -294,12 +362,12 @@ class ClaudeCliDetector {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status: installation.installed ? 'installed' : 'not_installed',
|
status: installation.installed ? "installed" : "not_installed",
|
||||||
installed: installation.installed,
|
installed: installation.installed,
|
||||||
path: installation.path,
|
path: installation.path,
|
||||||
version: installation.version,
|
version: installation.version,
|
||||||
method: installation.method,
|
method: installation.method,
|
||||||
auth
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,9 +401,9 @@ class ClaudeCliDetector {
|
|||||||
*/
|
*/
|
||||||
static getInstallCommands() {
|
static getInstallCommands() {
|
||||||
return {
|
return {
|
||||||
macos: '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',
|
windows: "irm https://claude.ai/install.ps1 | iex",
|
||||||
linux: 'curl -fsSL https://claude.ai/install.sh | bash'
|
linux: "curl -fsSL https://claude.ai/install.sh | bash",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,64 +417,69 @@ class ClaudeCliDetector {
|
|||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
let command, args;
|
let command, args;
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === "win32") {
|
||||||
command = 'powershell';
|
command = "powershell";
|
||||||
args = ['-Command', 'irm https://claude.ai/install.ps1 | iex'];
|
args = ["-Command", "irm https://claude.ai/install.ps1 | iex"];
|
||||||
} else {
|
} else {
|
||||||
command = 'bash';
|
command = "bash";
|
||||||
args = ['-c', 'curl -fsSL https://claude.ai/install.sh | 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, {
|
const proc = spawn(command, args, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
shell: false
|
shell: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = '';
|
let output = "";
|
||||||
let errorOutput = '';
|
let errorOutput = "";
|
||||||
|
|
||||||
proc.stdout.on('data', (data) => {
|
proc.stdout.on("data", (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
output += text;
|
output += text;
|
||||||
if (onProgress) {
|
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();
|
const text = data.toString();
|
||||||
errorOutput += text;
|
errorOutput += text;
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({ type: 'stderr', data: text });
|
onProgress({ type: "stderr", data: text });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on("close", (code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
console.log('[ClaudeCliDetector] Installation completed successfully');
|
console.log(
|
||||||
|
"[ClaudeCliDetector] Installation completed successfully"
|
||||||
|
);
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
output,
|
output,
|
||||||
message: 'Claude CLI installed successfully'
|
message: "Claude CLI installed successfully",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('[ClaudeCliDetector] Installation failed with code:', code);
|
console.error(
|
||||||
|
"[ClaudeCliDetector] Installation failed with code:",
|
||||||
|
code
|
||||||
|
);
|
||||||
reject({
|
reject({
|
||||||
success: false,
|
success: false,
|
||||||
error: errorOutput || `Installation failed with code ${code}`,
|
error: errorOutput || `Installation failed with code ${code}`,
|
||||||
output
|
output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (error) => {
|
proc.on("error", (error) => {
|
||||||
console.error('[ClaudeCliDetector] Installation error:', error);
|
console.error("[ClaudeCliDetector] Installation error:", error);
|
||||||
reject({
|
reject({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
output
|
output,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -422,22 +495,22 @@ class ClaudeCliDetector {
|
|||||||
if (!detection.installed) {
|
if (!detection.installed) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Claude CLI is not installed. Please install it first.',
|
error: "Claude CLI is not installed. Please install it first.",
|
||||||
installCommands: this.getInstallCommands()
|
installCommands: this.getInstallCommands(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
command: 'claude setup-token',
|
command: "claude setup-token",
|
||||||
instructions: [
|
instructions: [
|
||||||
'1. Open your terminal',
|
"1. Open your terminal",
|
||||||
'2. Run: claude setup-token',
|
"2. Run: claude setup-token",
|
||||||
'3. Follow the prompts to authenticate',
|
"3. Follow the prompts to authenticate",
|
||||||
'4. Copy the token that is displayed',
|
"4. Copy the token that is displayed",
|
||||||
'5. Paste the token in the field below'
|
"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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,38 +72,109 @@ class CodexCliDetector {
|
|||||||
|
|
||||||
// Check if auth file exists
|
// Check if auth file exists
|
||||||
if (fs.existsSync(authPath)) {
|
if (fs.existsSync(authPath)) {
|
||||||
const content = fs.readFileSync(authPath, 'utf-8');
|
console.log('[CodexCliDetector] Auth file exists, reading content...');
|
||||||
const auth = JSON.parse(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)
|
// Check for token object structure (from codex auth login)
|
||||||
// Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... }
|
// Structure: { token: { Id_token, access_token, refresh_token }, last_refresh: ... }
|
||||||
if (auth.token && typeof auth.token === 'object') {
|
if (auth.token && typeof auth.token === 'object') {
|
||||||
const token = auth.token;
|
const token = auth.token;
|
||||||
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
|
if (token.Id_token || token.access_token || token.refresh_token || token.id_token) {
|
||||||
return {
|
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,
|
authenticated: true,
|
||||||
method: 'auth_file',
|
method: 'auth_file',
|
||||||
hasAuthFile: true,
|
hasAuthFile: true,
|
||||||
hasEnvKey: !!envApiKey,
|
hasEnvKey: !!envApiKey,
|
||||||
authPath
|
authPath
|
||||||
};
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (auth_file - API key):', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('[CodexCliDetector] Error reading/parsing auth file:', error.message);
|
||||||
// Check for various possible auth fields that codex might use
|
// If we can't parse the file, we can't determine auth status
|
||||||
if (auth.api_key || auth.openai_api_key || auth.access_token || auth.apiKey) {
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: false,
|
||||||
method: 'auth_file',
|
method: 'none',
|
||||||
hasAuthFile: true,
|
hasAuthFile: false,
|
||||||
hasEnvKey: !!envApiKey,
|
hasEnvKey: !!envApiKey,
|
||||||
authPath
|
authPath
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if the file has any meaningful content (non-empty object)
|
// 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);
|
const keys = Object.keys(auth);
|
||||||
|
console.log('[CodexCliDetector] File has content, keys:', keys);
|
||||||
if (keys.length > 0) {
|
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
|
// File exists and has content, likely authenticated
|
||||||
// Try to verify by checking if codex command works
|
// Try to verify by checking if codex command works
|
||||||
try {
|
try {
|
||||||
@@ -116,34 +187,45 @@ class CodexCliDetector {
|
|||||||
timeout: 3000
|
timeout: 3000
|
||||||
});
|
});
|
||||||
// If command succeeds, assume authenticated
|
// 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,
|
authenticated: true,
|
||||||
method: 'auth_file',
|
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||||
hasAuthFile: true,
|
hasAuthFile: true,
|
||||||
hasEnvKey: !!envApiKey,
|
hasEnvKey: !!envApiKey,
|
||||||
authPath
|
authPath
|
||||||
};
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (verified via CLI, method:', result.method, '):', result);
|
||||||
|
return result;
|
||||||
} catch (cmdError) {
|
} catch (cmdError) {
|
||||||
// Command failed, but file exists - might still be authenticated
|
// Command failed, but file exists - might still be authenticated
|
||||||
// Return authenticated if file has content
|
// Check if it's likely tokens
|
||||||
return {
|
const likelyTokens = keys.some(key => key.toLowerCase().includes('token') || key.toLowerCase().includes('refresh'));
|
||||||
|
const result = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: 'auth_file',
|
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||||
hasAuthFile: true,
|
hasAuthFile: true,
|
||||||
hasEnvKey: !!envApiKey,
|
hasEnvKey: !!envApiKey,
|
||||||
authPath
|
authPath
|
||||||
};
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (file exists, method:', result.method, '):', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
// Verification failed, but file exists with content
|
// 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,
|
authenticated: true,
|
||||||
method: 'auth_file',
|
method: likelyTokens ? 'cli_tokens' : 'auth_file',
|
||||||
hasAuthFile: true,
|
hasAuthFile: true,
|
||||||
hasEnvKey: !!envApiKey,
|
hasEnvKey: !!envApiKey,
|
||||||
authPath
|
authPath
|
||||||
};
|
};
|
||||||
|
console.log('[CodexCliDetector] Auth result (fallback, method:', result.method, '):', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,3 +349,5 @@ class CodexConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new CodexConfigManager();
|
module.exports = new CodexConfigManager();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -345,3 +345,5 @@ process.on('SIGINT', () => {
|
|||||||
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
|
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
|
||||||
console.error(`[McpServerStdio] Project path: ${projectPath}`);
|
console.error(`[McpServerStdio] Project path: ${projectPath}`);
|
||||||
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);
|
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, formatShortcut } from "@/store/app-store";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
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"
|
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"
|
data-testid="sidebar-toggle-shortcut"
|
||||||
>
|
>
|
||||||
{shortcuts.toggleSidebar}
|
{formatShortcut(shortcuts.toggleSidebar, true)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -790,8 +790,8 @@ export function Sidebar() {
|
|||||||
data-testid="open-project-button"
|
data-testid="open-project-button"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-4 h-4 shrink-0" />
|
<FolderOpen className="w-4 h-4 shrink-0" />
|
||||||
<span className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70 ml-2">
|
||||||
{shortcuts.openProject}
|
{formatShortcut(shortcuts.openProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -830,10 +830,10 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70"
|
||||||
data-testid="project-picker-shortcut"
|
data-testid="project-picker-shortcut"
|
||||||
>
|
>
|
||||||
{shortcuts.projectPicker}
|
{formatShortcut(shortcuts.projectPicker, true)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
@@ -980,7 +980,7 @@ export function Sidebar() {
|
|||||||
<Undo2 className="w-4 h-4 mr-2" />
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Previous</span>
|
<span className="flex-1">Previous</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{shortcuts.cyclePrevProject}
|
{formatShortcut(shortcuts.cyclePrevProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -990,7 +990,7 @@ export function Sidebar() {
|
|||||||
<Redo2 className="w-4 h-4 mr-2" />
|
<Redo2 className="w-4 h-4 mr-2" />
|
||||||
<span className="flex-1">Next</span>
|
<span className="flex-1">Next</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
<span className="text-[10px] font-mono text-muted-foreground ml-2">
|
||||||
{shortcuts.cycleNextProject}
|
{formatShortcut(shortcuts.cycleNextProject, true)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -1077,13 +1077,13 @@ export function Sidebar() {
|
|||||||
{item.shortcut && sidebarOpen && (
|
{item.shortcut && sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||||
isActive &&
|
isActive &&
|
||||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||||
)}
|
)}
|
||||||
data-testid={`shortcut-${item.id}`}
|
data-testid={`shortcut-${item.id}`}
|
||||||
>
|
>
|
||||||
{item.shortcut}
|
{formatShortcut(item.shortcut, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Tooltip for collapsed state */}
|
{/* Tooltip for collapsed state */}
|
||||||
@@ -1214,13 +1214,13 @@ export function Sidebar() {
|
|||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex items-center justify-center w-5 h-5 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
"hidden lg:flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-mono rounded bg-brand-500/10 border border-brand-500/30 text-brand-400/70",
|
||||||
isActiveRoute("settings") &&
|
isActiveRoute("settings") &&
|
||||||
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
"bg-brand-500/20 border-brand-500/50 text-brand-400"
|
||||||
)}
|
)}
|
||||||
data-testid="shortcut-settings"
|
data-testid="shortcut-settings"
|
||||||
>
|
>
|
||||||
{shortcuts.settings}
|
{formatShortcut(shortcuts.settings, true)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
|
|||||||
639
app/src/components/ui/keyboard-map.tsx
Normal file
639
app/src/components/ui/keyboard-map.tsx
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||||
|
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
// Detect if running on Mac
|
||||||
|
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
// Keyboard layout - US QWERTY
|
||||||
|
const KEYBOARD_ROWS = [
|
||||||
|
// Number row
|
||||||
|
[
|
||||||
|
{ key: "`", label: "`", width: 1 },
|
||||||
|
{ key: "1", label: "1", width: 1 },
|
||||||
|
{ key: "2", label: "2", width: 1 },
|
||||||
|
{ key: "3", label: "3", width: 1 },
|
||||||
|
{ key: "4", label: "4", width: 1 },
|
||||||
|
{ key: "5", label: "5", width: 1 },
|
||||||
|
{ key: "6", label: "6", width: 1 },
|
||||||
|
{ key: "7", label: "7", width: 1 },
|
||||||
|
{ key: "8", label: "8", width: 1 },
|
||||||
|
{ key: "9", label: "9", width: 1 },
|
||||||
|
{ key: "0", label: "0", width: 1 },
|
||||||
|
{ key: "-", label: "-", width: 1 },
|
||||||
|
{ key: "=", label: "=", width: 1 },
|
||||||
|
],
|
||||||
|
// Top letter row
|
||||||
|
[
|
||||||
|
{ key: "Q", label: "Q", width: 1 },
|
||||||
|
{ key: "W", label: "W", width: 1 },
|
||||||
|
{ key: "E", label: "E", width: 1 },
|
||||||
|
{ key: "R", label: "R", width: 1 },
|
||||||
|
{ key: "T", label: "T", width: 1 },
|
||||||
|
{ key: "Y", label: "Y", width: 1 },
|
||||||
|
{ key: "U", label: "U", width: 1 },
|
||||||
|
{ key: "I", label: "I", width: 1 },
|
||||||
|
{ key: "O", label: "O", width: 1 },
|
||||||
|
{ key: "P", label: "P", width: 1 },
|
||||||
|
{ key: "[", label: "[", width: 1 },
|
||||||
|
{ key: "]", label: "]", width: 1 },
|
||||||
|
{ key: "\\", label: "\\", width: 1 },
|
||||||
|
],
|
||||||
|
// Home row
|
||||||
|
[
|
||||||
|
{ key: "A", label: "A", width: 1 },
|
||||||
|
{ key: "S", label: "S", width: 1 },
|
||||||
|
{ key: "D", label: "D", width: 1 },
|
||||||
|
{ key: "F", label: "F", width: 1 },
|
||||||
|
{ key: "G", label: "G", width: 1 },
|
||||||
|
{ key: "H", label: "H", width: 1 },
|
||||||
|
{ key: "J", label: "J", width: 1 },
|
||||||
|
{ key: "K", label: "K", width: 1 },
|
||||||
|
{ key: "L", label: "L", width: 1 },
|
||||||
|
{ key: ";", label: ";", width: 1 },
|
||||||
|
{ key: "'", label: "'", width: 1 },
|
||||||
|
],
|
||||||
|
// Bottom letter row
|
||||||
|
[
|
||||||
|
{ key: "Z", label: "Z", width: 1 },
|
||||||
|
{ key: "X", label: "X", width: 1 },
|
||||||
|
{ key: "C", label: "C", width: 1 },
|
||||||
|
{ key: "V", label: "V", width: 1 },
|
||||||
|
{ key: "B", label: "B", width: 1 },
|
||||||
|
{ key: "N", label: "N", width: 1 },
|
||||||
|
{ key: "M", label: "M", width: 1 },
|
||||||
|
{ key: ",", label: ",", width: 1 },
|
||||||
|
{ key: ".", label: ".", width: 1 },
|
||||||
|
{ key: "/", label: "/", width: 1 },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map shortcut names to human-readable labels
|
||||||
|
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||||
|
board: "Kanban Board",
|
||||||
|
agent: "Agent Runner",
|
||||||
|
spec: "Spec Editor",
|
||||||
|
context: "Context",
|
||||||
|
tools: "Agent Tools",
|
||||||
|
settings: "Settings",
|
||||||
|
profiles: "AI Profiles",
|
||||||
|
toggleSidebar: "Toggle Sidebar",
|
||||||
|
addFeature: "Add Feature",
|
||||||
|
addContextFile: "Add Context File",
|
||||||
|
startNext: "Start Next",
|
||||||
|
newSession: "New Session",
|
||||||
|
openProject: "Open Project",
|
||||||
|
projectPicker: "Project Picker",
|
||||||
|
cyclePrevProject: "Prev Project",
|
||||||
|
cycleNextProject: "Next Project",
|
||||||
|
addProfile: "Add Profile",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Categorize shortcuts for color coding
|
||||||
|
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||||
|
board: "navigation",
|
||||||
|
agent: "navigation",
|
||||||
|
spec: "navigation",
|
||||||
|
context: "navigation",
|
||||||
|
tools: "navigation",
|
||||||
|
settings: "navigation",
|
||||||
|
profiles: "navigation",
|
||||||
|
toggleSidebar: "ui",
|
||||||
|
addFeature: "action",
|
||||||
|
addContextFile: "action",
|
||||||
|
startNext: "action",
|
||||||
|
newSession: "action",
|
||||||
|
openProject: "action",
|
||||||
|
projectPicker: "action",
|
||||||
|
cyclePrevProject: "action",
|
||||||
|
cycleNextProject: "action",
|
||||||
|
addProfile: "action",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category colors
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
navigation: {
|
||||||
|
bg: "bg-blue-500/20",
|
||||||
|
border: "border-blue-500/50",
|
||||||
|
text: "text-blue-400",
|
||||||
|
label: "Navigation",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
bg: "bg-purple-500/20",
|
||||||
|
border: "border-purple-500/50",
|
||||||
|
text: "text-purple-400",
|
||||||
|
label: "UI Controls",
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
bg: "bg-green-500/20",
|
||||||
|
border: "border-green-500/50",
|
||||||
|
text: "text-green-400",
|
||||||
|
label: "Actions",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KeyboardMapProps {
|
||||||
|
onKeySelect?: (key: string) => void;
|
||||||
|
selectedKey?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||||
|
const { keyboardShortcuts } = useAppStore();
|
||||||
|
|
||||||
|
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||||
|
const keyToShortcuts = React.useMemo(() => {
|
||||||
|
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
||||||
|
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||||
|
([shortcutName, shortcutStr]) => {
|
||||||
|
const parsed = parseShortcut(shortcutStr);
|
||||||
|
const normalizedKey = parsed.key.toUpperCase();
|
||||||
|
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||||
|
if (!map[normalizedKey]) {
|
||||||
|
map[normalizedKey] = [];
|
||||||
|
}
|
||||||
|
map[normalizedKey].push({ name: shortcutName, hasModifiers });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return map;
|
||||||
|
}, [keyboardShortcuts]);
|
||||||
|
|
||||||
|
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||||
|
const normalizedKey = keyDef.key.toUpperCase();
|
||||||
|
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||||
|
const shortcuts = shortcutInfos.map(s => s.name);
|
||||||
|
const isBound = shortcuts.length > 0;
|
||||||
|
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||||
|
const isModified = shortcuts.some(
|
||||||
|
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get category for coloring (use first shortcut's category if multiple)
|
||||||
|
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||||
|
const colors = category ? CATEGORY_COLORS[category] : null;
|
||||||
|
|
||||||
|
const keyElement = (
|
||||||
|
<button
|
||||||
|
key={keyDef.key}
|
||||||
|
onClick={() => onKeySelect?.(keyDef.key)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||||
|
"h-12 min-w-11 py-1",
|
||||||
|
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||||
|
// Base styles
|
||||||
|
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||||
|
// Bound key styles
|
||||||
|
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||||
|
// Selected state
|
||||||
|
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||||
|
// Modified indicator
|
||||||
|
isModified && "ring-1 ring-yellow-500/50"
|
||||||
|
)}
|
||||||
|
data-testid={`keyboard-key-${keyDef.key}`}
|
||||||
|
>
|
||||||
|
{/* Key label - always at top */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-mono font-bold leading-none",
|
||||||
|
isBound && colors ? colors.text : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{keyDef.label}
|
||||||
|
</span>
|
||||||
|
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||||
|
isBound && shortcuts.length > 0
|
||||||
|
? (colors ? colors.text : "text-muted-foreground")
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBound && shortcuts.length > 0
|
||||||
|
? (shortcuts.length === 1
|
||||||
|
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
|
||||||
|
: `${shortcuts.length}x`)
|
||||||
|
: "\u00A0" // Non-breaking space to maintain height
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{isModified && (
|
||||||
|
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wrap in tooltip if bound
|
||||||
|
if (isBound) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={keyDef.key}>
|
||||||
|
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
const shortcutStr = keyboardShortcuts[shortcut];
|
||||||
|
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||||
|
return (
|
||||||
|
<div key={shortcut} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"w-2 h-2 rounded-full",
|
||||||
|
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
|
||||||
|
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||||
|
{displayShortcut}
|
||||||
|
</kbd>
|
||||||
|
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||||
|
<span className="text-xs text-yellow-400">(custom)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||||
|
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||||
|
<div key={key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 rounded border",
|
||||||
|
colors.bg,
|
||||||
|
colors.border
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={colors.text}>{colors.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||||
|
<span className="text-muted-foreground">Available</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
<span className="text-yellow-400">Modified</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard layout */}
|
||||||
|
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||||
|
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||||
|
{row.map(renderKey)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||||
|
configured
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{Object.keys(keyToShortcuts).length}
|
||||||
|
</strong>{" "}
|
||||||
|
keys in use
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||||
|
</strong>{" "}
|
||||||
|
keys available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<keyof KeyboardShortcuts | null>(null);
|
||||||
|
const [keyValue, setKeyValue] = React.useState("");
|
||||||
|
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||||
|
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const groupedShortcuts = React.useMemo(() => {
|
||||||
|
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||||
|
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 (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||||
|
{editable && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => resetKeyboardShortcuts()}
|
||||||
|
className="gap-2 text-xs"
|
||||||
|
data-testid="reset-all-shortcuts-button"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Reset All to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||||
|
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||||
|
return (
|
||||||
|
<div key={category} className="space-y-2">
|
||||||
|
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||||
|
{colors.label}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{shortcuts.map(({ key, label, value }) => {
|
||||||
|
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||||
|
const isEditing = editingShortcut === key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||||
|
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||||
|
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||||
|
data-testid={`shortcut-row-${key}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Modifier checkboxes */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-cmd-${key}`}
|
||||||
|
checked={modifiers.cmdCtrl}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
{isMac ? "⌘" : "Ctrl"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-alt-${key}`}
|
||||||
|
checked={modifiers.alt}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
{isMac ? "⌥" : "Alt"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`mod-shift-${key}`}
|
||||||
|
checked={modifiers.shift}
|
||||||
|
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||||
|
⇧
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">+</span>
|
||||||
|
<Input
|
||||||
|
value={keyValue}
|
||||||
|
onChange={(e) => 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}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSaveShortcut();
|
||||||
|
}}
|
||||||
|
disabled={!!shortcutError || !keyValue}
|
||||||
|
data-testid={`save-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelEdit();
|
||||||
|
}}
|
||||||
|
data-testid={`cancel-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<kbd
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs font-mono rounded border",
|
||||||
|
colors.bg,
|
||||||
|
colors.border,
|
||||||
|
colors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatShortcut(value, true)}
|
||||||
|
</kbd>
|
||||||
|
{isModified && editable && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleResetShortcut(key);
|
||||||
|
}}
|
||||||
|
data-testid={`reset-shortcut-${key}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isModified && !editable && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||||
|
)}
|
||||||
|
{editable && !isModified && (
|
||||||
|
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ interface MarkdownProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable Markdown component for rendering markdown content
|
* 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) {
|
export function Markdown({ children, className }: MarkdownProps) {
|
||||||
return (
|
return (
|
||||||
@@ -18,27 +18,27 @@ export function Markdown({ children, className }: MarkdownProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"prose prose-sm prose-invert max-w-none",
|
"prose prose-sm prose-invert max-w-none",
|
||||||
// Headings
|
// Headings
|
||||||
"[&_h1]:text-xl [&_h1]:text-zinc-200 [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
|
"[&_h1]:text-xl [&_h1]:text-foreground [&_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",
|
"[&_h2]:text-lg [&_h2]:text-foreground [&_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",
|
"[&_h3]:text-base [&_h3]:text-foreground [&_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",
|
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
|
||||||
// Paragraphs
|
// Paragraphs
|
||||||
"[&_p]:text-zinc-300 [&_p]:leading-relaxed [&_p]:my-2",
|
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
|
||||||
// Lists
|
// Lists
|
||||||
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
|
"[&_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
|
||||||
"[&_code]:text-cyan-400 [&_code]:bg-zinc-800/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
|
"[&_code]:text-chart-2 [&_code]:bg-muted [&_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",
|
"[&_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",
|
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
|
||||||
// Strong/Bold
|
// Strong/Bold
|
||||||
"[&_strong]:text-zinc-200 [&_strong]:font-semibold",
|
"[&_strong]:text-foreground [&_strong]:font-semibold",
|
||||||
// Links
|
// Links
|
||||||
"[&_a]:text-blue-400 [&_a]:no-underline hover:[&_a]:underline",
|
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
|
||||||
// Blockquotes
|
// 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
|
// Horizontal rules
|
||||||
"[&_hr]:border-zinc-700 [&_hr]:my-4",
|
"[&_hr]:border-border [&_hr]:my-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={inputId} className="text-foreground">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
{hasStoredKey && <CheckCircle2 className="w-4 h-4 text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type={showValue ? "text" : "password"}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground"
|
||||||
|
data-testid={inputTestId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||||
|
onClick={() => setShowValue(!showValue)}
|
||||||
|
data-testid={toggleTestId}
|
||||||
|
>
|
||||||
|
{showValue ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={testButton.onClick}
|
||||||
|
disabled={testButton.disabled}
|
||||||
|
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
|
||||||
|
data-testid={testButton.testId}
|
||||||
|
>
|
||||||
|
{testButton.loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Test
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{descriptionPrefix}{" "}
|
||||||
|
<a
|
||||||
|
href={descriptionLinkHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand-500 hover:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
{descriptionLinkText}
|
||||||
|
</a>
|
||||||
|
{descriptionSuffix}
|
||||||
|
</p>
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||||
|
result.success
|
||||||
|
? "bg-green-500/10 border border-green-500/20 text-green-400"
|
||||||
|
: "bg-red-500/10 border border-red-500/20 text-red-400"
|
||||||
|
}`}
|
||||||
|
data-testid={resultTestId}
|
||||||
|
>
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm" data-testid={resultMessageTestId}>
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="api-keys"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Key className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">API Keys</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure your AI provider API keys. Keys are stored locally in your
|
||||||
|
browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* API Key Fields */}
|
||||||
|
{providerConfigs.map((provider) => (
|
||||||
|
<ApiKeyField key={provider.key} config={provider} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Authentication Status Display */}
|
||||||
|
<AuthenticationStatusDisplay
|
||||||
|
claudeAuthStatus={claudeAuthStatus}
|
||||||
|
codexAuthStatus={codexAuthStatus}
|
||||||
|
apiKeyStatus={apiKeyStatus}
|
||||||
|
apiKeys={apiKeys}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Security Notice */}
|
||||||
|
<SecurityNotice />
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex items-center gap-4 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
data-testid="save-settings"
|
||||||
|
className="min-w-[120px] bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Saved!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save API Keys"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4 pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Info className="w-4 h-4 text-brand-500" />
|
||||||
|
<Label className="text-foreground font-semibold">
|
||||||
|
Current Authentication Configuration
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Claude Authentication Status */}
|
||||||
|
<div className="p-3 rounded-lg bg-card border border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Terminal className="w-4 h-4 text-brand-500" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Claude (Anthropic)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-xs min-h-12">
|
||||||
|
{claudeAuthStatus?.authenticated ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Method:{" "}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{claudeAuthStatus.method === "oauth"
|
||||||
|
? "OAuth Token"
|
||||||
|
: claudeAuthStatus.method === "api_key"
|
||||||
|
? "API Key"
|
||||||
|
: "Unknown"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{claudeAuthStatus.oauthTokenValid && (
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
|
<span>OAuth token configured</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{claudeAuthStatus.apiKeyValid && (
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
|
<span>API key configured</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeyStatus?.hasAnthropicKey && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Environment variable detected</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeys.anthropic && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Manual API key in settings</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : apiKeyStatus?.hasAnthropicKey ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
|
||||||
|
</div>
|
||||||
|
) : apiKeys.anthropic ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using manual API key from settings</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground py-0.5">
|
||||||
|
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
<span className="text-xs">Not Setup</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Codex/OpenAI Authentication Status */}
|
||||||
|
<div className="p-3 rounded-lg bg-card border border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Atom className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Codex (OpenAI)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-xs min-h-12">
|
||||||
|
{codexAuthStatus?.authenticated ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Method:{" "}
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{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"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{codexAuthStatus.method === "cli_verified" ||
|
||||||
|
codexAuthStatus.method === "cli_tokens" ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Account authenticated</span>
|
||||||
|
</div>
|
||||||
|
) : codexAuthStatus.apiKeyValid ? (
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
|
<span>API key configured</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{apiKeyStatus?.hasOpenAIKey && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Environment variable detected</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeys.openai && (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Manual API key in settings</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : apiKeyStatus?.hasOpenAIKey ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using environment variable (OPENAI_API_KEY)</span>
|
||||||
|
</div>
|
||||||
|
) : apiKeys.openai ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using manual API key from settings</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground py-0.5">
|
||||||
|
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
<span className="text-xs">Not Setup</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google/Gemini Authentication Status */}
|
||||||
|
<div className="p-3 rounded-lg bg-card border border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Gemini (Google)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-xs min-h-12">
|
||||||
|
{apiKeyStatus?.hasGoogleKey ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using environment variable (GOOGLE_API_KEY)</span>
|
||||||
|
</div>
|
||||||
|
) : apiKeys.google ? (
|
||||||
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
|
<span>Using manual API key from settings</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground py-0.5">
|
||||||
|
<AlertCircle className="w-2.5 h-2.5 shrink-0" />
|
||||||
|
<span className="text-xs">Not Setup</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<TestResult | null>(null);
|
||||||
|
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
|
||||||
|
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
||||||
|
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// API key status from environment
|
||||||
|
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-yellow-500">{title}</p>
|
||||||
|
<p className="text-yellow-500/80 text-xs mt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="appearance"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Palette className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Appearance</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize the look and feel of your application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-foreground">
|
||||||
|
Theme{" "}
|
||||||
|
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{themeOptions.map(({ value, label, Icon, testId }) => {
|
||||||
|
const isActive = effectiveTheme === value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
variant={isActive ? "secondary" : "outline"}
|
||||||
|
onClick={() => onThemeChange(value)}
|
||||||
|
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
||||||
|
isActive ? "border-brand-500 ring-1 ring-brand-500/50" : ""
|
||||||
|
}`}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="font-medium text-sm">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="claude"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Claude Code CLI
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isChecking}
|
||||||
|
data-testid="refresh-claude-cli"
|
||||||
|
title="Refresh Claude CLI detection"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Claude Code CLI provides better performance for long-running tasks,
|
||||||
|
especially with ultrathink.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{status.success && status.status === "installed" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-400">
|
||||||
|
Claude Code CLI Installed
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
||||||
|
{status.method && (
|
||||||
|
<p>
|
||||||
|
Method: <span className="font-mono">{status.method}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.version && (
|
||||||
|
<p>
|
||||||
|
Version:{" "}
|
||||||
|
<span className="font-mono">{status.version}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.path && (
|
||||||
|
<p className="truncate" title={status.path}>
|
||||||
|
Path:{" "}
|
||||||
|
<span className="font-mono text-[10px]">
|
||||||
|
{status.path}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.recommendation && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{status.recommendation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-yellow-400">
|
||||||
|
Claude Code CLI Not Detected
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-yellow-400/80 mt-1">
|
||||||
|
{status.recommendation ||
|
||||||
|
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.installCommands && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-foreground-secondary">
|
||||||
|
Installation Commands:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{status.installCommands.npm && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.npm}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.installCommands.macos && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
macOS/Linux:
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.macos}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.installCommands.windows && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Windows (PowerShell):
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.windows}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="codex"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5 text-green-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
OpenAI Codex CLI
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isChecking}
|
||||||
|
data-testid="refresh-codex-cli"
|
||||||
|
title="Refresh Codex CLI detection"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{status.success && status.status === "installed" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-400">
|
||||||
|
Codex CLI Installed
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
||||||
|
{status.method && (
|
||||||
|
<p>
|
||||||
|
Method: <span className="font-mono">{status.method}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.version && (
|
||||||
|
<p>
|
||||||
|
Version:{" "}
|
||||||
|
<span className="font-mono">{status.version}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.path && (
|
||||||
|
<p className="truncate" title={status.path}>
|
||||||
|
Path:{" "}
|
||||||
|
<span className="font-mono text-[10px]">
|
||||||
|
{status.path}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.recommendation && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{status.recommendation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : status.status === "api_key_only" ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-blue-400">
|
||||||
|
API Key Detected - CLI Not Installed
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-400/80 mt-1">
|
||||||
|
{status.recommendation ||
|
||||||
|
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.installCommands && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-foreground-secondary">
|
||||||
|
Installation Commands:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{status.installCommands.npm && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.npm}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-yellow-400">
|
||||||
|
Codex CLI Not Detected
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-yellow-400/80 mt-1">
|
||||||
|
{status.recommendation ||
|
||||||
|
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.installCommands && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-foreground-secondary">
|
||||||
|
Installation Commands:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{status.installCommands.npm && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.npm}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.installCommands.macos && (
|
||||||
|
<div className="p-2 rounded bg-background border border-border-glass">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
macOS (Homebrew):
|
||||||
|
</p>
|
||||||
|
<code className="text-xs text-foreground-secondary font-mono break-all">
|
||||||
|
{status.installCommands.macos}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-popover border-border max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="w-5 h-5 text-destructive" />
|
||||||
|
Delete Project
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Are you sure you want to move this project to Trash?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{project && (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
||||||
|
<Folder className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The folder will remain on disk until you permanently delete it from Trash.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
data-testid="confirm-delete-project"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Move to Trash
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-popover border-border max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Keyboard className="w-5 h-5 text-brand-500" />
|
||||||
|
Keyboard Shortcut Map
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Visual overview of all keyboard shortcuts. Keys in color are bound to
|
||||||
|
shortcuts. Click on any shortcut below to edit it.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-6 py-4 pl-3 pr-6 pb-6">
|
||||||
|
{/* Visual Keyboard Map */}
|
||||||
|
<KeyboardMap />
|
||||||
|
|
||||||
|
{/* Shortcut Reference - Editable */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
All Shortcuts Reference (Click to Edit)
|
||||||
|
</h3>
|
||||||
|
<ShortcutReferencePanel editable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
||||||
|
<Settings className="w-5 h-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<nav className="hidden lg:block w-48 shrink-0 border-r border-border bg-card/50 backdrop-blur-sm">
|
||||||
|
<div className="sticky top-0 p-4 space-y-1">
|
||||||
|
{navItems
|
||||||
|
.filter((item) => item.id !== "danger" || currentProject)
|
||||||
|
.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeSection === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all text-left",
|
||||||
|
isActive
|
||||||
|
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 shrink-0",
|
||||||
|
isActive ? "text-brand-500" : ""
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/src/components/views/settings-view/config/navigation.ts
Normal file
29
app/src/components/views/settings-view/config/navigation.ts
Normal file
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="danger"
|
||||||
|
className="rounded-xl border border-destructive/30 bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-destructive/30">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Trash2 className="w-5 h-5 text-destructive" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently remove this project from Automaker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
|
||||||
|
<Folder className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{project.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{project.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onDeleteClick}
|
||||||
|
data-testid="delete-project-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="defaults"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<FlaskConical className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Feature Defaults
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure default settings for new features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Profiles Only Setting */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="show-profiles-only"
|
||||||
|
checked={showProfilesOnly}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onShowProfilesOnlyChange(checked === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
data-testid="show-profiles-only-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="show-profiles-only"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 text-brand-500" />
|
||||||
|
Show profiles only by default
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Skip Tests Setting */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="default-skip-tests"
|
||||||
|
checked={defaultSkipTests}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onDefaultSkipTestsChange(checked === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
data-testid="default-skip-tests-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="default-skip-tests"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4 text-brand-500" />
|
||||||
|
Skip automated testing by default
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, new features will default to manual verification
|
||||||
|
instead of TDD (test-driven development). You can still override
|
||||||
|
this for individual features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Worktree Isolation Setting */}
|
||||||
|
<div className="space-y-3 pt-2 border-t border-border">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id="use-worktrees"
|
||||||
|
checked={useWorktrees}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUseWorktreesChange(checked === true)
|
||||||
|
}
|
||||||
|
className="mt-0.5"
|
||||||
|
data-testid="use-worktrees-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="use-worktrees"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<GitBranch className="w-4 h-4 text-brand-500" />
|
||||||
|
Enable Git Worktree Isolation (experimental)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
app/src/components/views/settings-view/hooks/use-cli-status.ts
Normal file
169
app/src/components/views/settings-view/hooks/use-cli-status.ts
Normal file
@@ -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<CliStatusResult | null>(null);
|
||||||
|
|
||||||
|
const [codexCliStatus, setCodexCliStatus] =
|
||||||
|
useState<CodexCliStatusResult | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="kanban"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<LayoutGrid className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Kanban Card Display
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Control how much information is displayed on Kanban cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-foreground">Detail Level</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Button
|
||||||
|
variant={detailLevel === "minimal" ? "secondary" : "outline"}
|
||||||
|
onClick={() => onChange("minimal")}
|
||||||
|
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
|
||||||
|
detailLevel === "minimal"
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
data-testid="kanban-detail-minimal"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-5 h-5" />
|
||||||
|
<span className="font-medium text-sm">Minimal</span>
|
||||||
|
<span className="text-xs text-muted-foreground text-center">
|
||||||
|
Title & category only
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={detailLevel === "standard" ? "secondary" : "outline"}
|
||||||
|
onClick={() => onChange("standard")}
|
||||||
|
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
|
||||||
|
detailLevel === "standard"
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
data-testid="kanban-detail-standard"
|
||||||
|
>
|
||||||
|
<Square className="w-5 h-5" />
|
||||||
|
<span className="font-medium text-sm">Standard</span>
|
||||||
|
<span className="text-xs text-muted-foreground text-center">
|
||||||
|
Steps & progress
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={detailLevel === "detailed" ? "secondary" : "outline"}
|
||||||
|
onClick={() => onChange("detailed")}
|
||||||
|
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 h-auto ${
|
||||||
|
detailLevel === "detailed"
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500/50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
data-testid="kanban-detail-detailed"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-5 h-5" />
|
||||||
|
<span className="font-medium text-sm">Detailed</span>
|
||||||
|
<span className="text-xs text-muted-foreground text-center">
|
||||||
|
Model, tools & tasks
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Minimal:</strong> Shows only title and category
|
||||||
|
<br />
|
||||||
|
<strong>Standard:</strong> Adds steps preview and progress bar
|
||||||
|
<br />
|
||||||
|
<strong>Detailed:</strong> Shows all info including model, tool
|
||||||
|
calls, task list, and summaries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
id="keyboard"
|
||||||
|
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Settings2 className="w-5 h-5 text-brand-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize keyboard shortcuts for navigation and actions using the
|
||||||
|
visual keyboard map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Centered message directing to keyboard map */}
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Keyboard className="w-16 h-16 text-brand-500/30" />
|
||||||
|
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-w-md">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
Use the Visual Keyboard Map
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={onOpenKeyboardMap}
|
||||||
|
className="gap-2 mt-4"
|
||||||
|
>
|
||||||
|
<Keyboard className="w-5 h-5" />
|
||||||
|
Open Keyboard Map
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
app/src/components/views/settings-view/shared/types.ts
Normal file
47
app/src/components/views/settings-view/shared/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} 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 { useAppStore } from "@/store/app-store";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import {
|
import {
|
||||||
@@ -780,6 +780,22 @@ function CodexSetupStep({
|
|||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [isSavingKey, setIsSavingKey] = useState(false);
|
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 () => {
|
const checkStatus = useCallback(async () => {
|
||||||
console.log("[Codex Setup] Starting status check...");
|
console.log("[Codex Setup] Starting status check...");
|
||||||
setIsChecking(true);
|
setIsChecking(true);
|
||||||
@@ -805,13 +821,16 @@ function CodexSetupStep({
|
|||||||
setCodexCliStatus(cliStatus);
|
setCodexCliStatus(cliStatus);
|
||||||
|
|
||||||
if (result.auth) {
|
if (result.auth) {
|
||||||
const authStatus = {
|
const method = mapAuthMethod(result.auth.method);
|
||||||
|
|
||||||
|
const authStatus: CodexAuthStatus = {
|
||||||
authenticated: result.auth.authenticated,
|
authenticated: result.auth.authenticated,
|
||||||
method: result.auth.method === "auth_file" ? "api_key" : result.auth.method === "env_var" ? "env" : "none",
|
method,
|
||||||
apiKeyValid: result.auth.authenticated,
|
// 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);
|
console.log("[Codex Setup] Auth Status:", authStatus);
|
||||||
setCodexAuthStatus(authStatus as any);
|
setCodexAuthStatus(authStatus);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Codex Setup] No auth info in result");
|
console.log("[Codex Setup] No auth info in result");
|
||||||
}
|
}
|
||||||
|
|||||||
149
app/src/config/api-providers.ts
Normal file
149
app/src/config/api-providers.ts
Normal file
@@ -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<SetStateAction<string>>;
|
||||||
|
showValue: boolean;
|
||||||
|
setShowValue: Dispatch<SetStateAction<boolean>>;
|
||||||
|
hasStoredKey: string | null | undefined;
|
||||||
|
inputTestId: string;
|
||||||
|
toggleTestId: string;
|
||||||
|
testButton: {
|
||||||
|
onClick: () => Promise<void> | 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<SetStateAction<string>>;
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
testing: boolean;
|
||||||
|
onTest: () => Promise<void>;
|
||||||
|
result: { success: boolean; message: string } | null;
|
||||||
|
};
|
||||||
|
google: {
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
testing: boolean;
|
||||||
|
onTest: () => Promise<void>;
|
||||||
|
result: { success: boolean; message: string } | null;
|
||||||
|
};
|
||||||
|
openai: {
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
testing: boolean;
|
||||||
|
onTest: () => Promise<void>;
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
88
app/src/config/theme-options.ts
Normal file
88
app/src/config/theme-options.ts
Normal file
@@ -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<ThemeOption> = [
|
||||||
|
{ 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react";
|
import { useEffect, useCallback } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, parseShortcut } from "@/store/app-store";
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
export interface KeyboardShortcut {
|
||||||
key: string;
|
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
|
||||||
action: () => void;
|
action: () => void;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -59,9 +59,44 @@ function isInputFocused(): boolean {
|
|||||||
return false;
|
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
|
* Hook to manage keyboard shortcuts
|
||||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
* 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[]) {
|
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
@@ -71,14 +106,9 @@ export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
|||||||
return;
|
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
|
// Find matching shortcut
|
||||||
const matchingShortcut = shortcuts.find(
|
const matchingShortcut = shortcuts.find(
|
||||||
(shortcut) => shortcut.key.toLowerCase() === event.key.toLowerCase()
|
(shortcut) => matchesShortcut(event, shortcut.key)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingShortcut) {
|
if (matchingShortcut) {
|
||||||
|
|||||||
104
app/src/hooks/use-scroll-tracking.ts
Normal file
104
app/src/hooks/use-scroll-tracking.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface ScrollTrackingItem {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScrollTrackingOptions<T extends ScrollTrackingItem> {
|
||||||
|
/** 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<T extends ScrollTrackingItem>({
|
||||||
|
items,
|
||||||
|
filterFn = () => true,
|
||||||
|
initialSection,
|
||||||
|
scrollOffset = 24,
|
||||||
|
}: UseScrollTrackingOptions<T>) {
|
||||||
|
const [activeSection, setActiveSection] = useState(
|
||||||
|
initialSection || items[0]?.id || ""
|
||||||
|
);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,3 +52,5 @@ export function useWindowState(): WindowState {
|
|||||||
return windowState;
|
return windowState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,9 @@ export interface ElectronAPI {
|
|||||||
method: string;
|
method: string;
|
||||||
hasCredentialsFile: boolean;
|
hasCredentialsFile: boolean;
|
||||||
hasToken: boolean;
|
hasToken: boolean;
|
||||||
|
hasStoredOAuthToken?: boolean;
|
||||||
|
hasStoredApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -327,9 +330,11 @@ export interface ElectronAPI {
|
|||||||
path?: string;
|
path?: string;
|
||||||
auth?: {
|
auth?: {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
method: string;
|
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
|
||||||
hasAuthFile: boolean;
|
hasAuthFile: boolean;
|
||||||
hasEnvKey: boolean;
|
hasEnvKey: boolean;
|
||||||
|
hasStoredApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -723,6 +728,9 @@ interface SetupAPI {
|
|||||||
method: string;
|
method: string;
|
||||||
hasCredentialsFile: boolean;
|
hasCredentialsFile: boolean;
|
||||||
hasToken: boolean;
|
hasToken: boolean;
|
||||||
|
hasStoredOAuthToken?: boolean;
|
||||||
|
hasStoredApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -734,9 +742,11 @@ interface SetupAPI {
|
|||||||
path?: string;
|
path?: string;
|
||||||
auth?: {
|
auth?: {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
method: string;
|
method: string; // Can be: "cli_verified", "cli_tokens", "auth_file", "env_var", "none"
|
||||||
hasAuthFile: boolean;
|
hasAuthFile: boolean;
|
||||||
hasEnvKey: boolean;
|
hasEnvKey: boolean;
|
||||||
|
hasStoredApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -38,7 +38,75 @@ export interface ApiKeys {
|
|||||||
openai: string;
|
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 {
|
export interface KeyboardShortcuts {
|
||||||
// Navigation shortcuts
|
// Navigation shortcuts
|
||||||
board: string;
|
board: string;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface ClaudeAuthStatus {
|
|||||||
// Codex Auth Status
|
// Codex Auth Status
|
||||||
export interface CodexAuthStatus {
|
export interface CodexAuthStatus {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
method: "api_key" | "env" | "none";
|
method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none";
|
||||||
apiKeyValid?: boolean;
|
apiKeyValid?: boolean;
|
||||||
mcpConfigured?: boolean;
|
mcpConfigured?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user