mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-03 04:57:42 +00:00
refactor(settings-modal): refactor settings modal to make it feature based
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,219 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { authenticatedFetch } from "../../utils/api";
|
|
||||||
import { ReleaseInfo } from "../../types/sharedTypes";
|
|
||||||
|
|
||||||
interface VersionUpgradeModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
releaseInfo: ReleaseInfo | null;
|
|
||||||
currentVersion: string;
|
|
||||||
latestVersion: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VersionUpgradeModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
releaseInfo,
|
|
||||||
currentVersion,
|
|
||||||
latestVersion
|
|
||||||
}: VersionUpgradeModalProps) {
|
|
||||||
const { t } = useTranslation('common');
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
const [updateOutput, setUpdateOutput] = useState('');
|
|
||||||
const [updateError, setUpdateError] = useState('');
|
|
||||||
|
|
||||||
const handleUpdateNow = useCallback(async () => {
|
|
||||||
setIsUpdating(true);
|
|
||||||
setUpdateOutput('Starting update...\n');
|
|
||||||
setUpdateError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call the backend API to run the update command
|
|
||||||
const response = await authenticatedFetch('/api/system/update', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setUpdateOutput(prev => prev + data.output + '\n');
|
|
||||||
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
|
||||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
|
||||||
} else {
|
|
||||||
setUpdateError(data.error || 'Update failed');
|
|
||||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setUpdateError(error.message);
|
|
||||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<button
|
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={t('versionUpdate.ariaLabels.closeModal')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('versionUpdate.title')}</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version Info */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('versionUpdate.currentVersion')}</span>
|
|
||||||
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
|
||||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('versionUpdate.latestVersion')}</span>
|
|
||||||
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Changelog */}
|
|
||||||
{releaseInfo?.body && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.whatsNew')}</h3>
|
|
||||||
{releaseInfo?.htmlUrl && (
|
|
||||||
<a
|
|
||||||
href={releaseInfo.htmlUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{t('versionUpdate.viewFullRelease')}
|
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
|
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
|
|
||||||
{cleanChangelog(releaseInfo.body)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Update Output */}
|
|
||||||
{(updateOutput || updateError) && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.updateProgress')}</h3>
|
|
||||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
|
|
||||||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
|
|
||||||
</div>
|
|
||||||
{updateError && (
|
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
|
|
||||||
{updateError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upgrade Instructions */}
|
|
||||||
{!isUpdating && !updateOutput && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
|
|
||||||
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
|
|
||||||
git checkout main && git pull && npm install
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{t('versionUpdate.manualUpgradeHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
|
|
||||||
</button>
|
|
||||||
{!updateOutput && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText('git checkout main && git pull && npm install');
|
|
||||||
}}
|
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
{t('versionUpdate.buttons.copyCommand')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleUpdateNow}
|
|
||||||
disabled={isUpdating}
|
|
||||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{isUpdating ? (
|
|
||||||
<>
|
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
{t('versionUpdate.buttons.updating')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('versionUpdate.buttons.updateNow')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up changelog by removing GitHub-specific metadata
|
|
||||||
const cleanChangelog = (body: string) => {
|
|
||||||
if (!body) return '';
|
|
||||||
|
|
||||||
return body
|
|
||||||
// Remove full commit hashes (40 character hex strings)
|
|
||||||
.replace(/\b[0-9a-f]{40}\b/gi, '')
|
|
||||||
// Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)
|
|
||||||
.replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '')
|
|
||||||
// Remove "Full Changelog" links
|
|
||||||
.replace(/\*\*Full Changelog\*\*:.*$/gim, '')
|
|
||||||
// Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)
|
|
||||||
.replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '')
|
|
||||||
// Clean up multiple consecutive empty lines
|
|
||||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
|
||||||
// Trim whitespace
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { Input } from '../ui/input';
|
|
||||||
import { Badge } from '../ui/badge';
|
|
||||||
import { Server, Plus, Edit3, Trash2, Terminal, Globe, Zap, X } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const getTransportIcon = (type) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'stdio': return <Terminal className="w-4 h-4" />;
|
|
||||||
case 'sse': return <Zap className="w-4 h-4" />;
|
|
||||||
case 'http': return <Globe className="w-4 h-4" />;
|
|
||||||
default: return <Server className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Claude MCP Servers
|
|
||||||
function ClaudeMcpServers({
|
|
||||||
servers,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onTest,
|
|
||||||
onDiscoverTools,
|
|
||||||
testResults,
|
|
||||||
serverTools,
|
|
||||||
toolsLoading,
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="w-5 h-5 text-purple-500" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
|
||||||
{t('mcpServers.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcpServers.description.claude')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
onClick={onAdd}
|
|
||||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map(server => (
|
|
||||||
<div key={server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{getTransportIcon(server.type)}
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{server.type}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{server.scope === 'local' ? t('mcpServers.scope.local') : server.scope === 'user' ? t('mcpServers.scope.user') : server.scope}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
{server.type === 'stdio' && server.config?.command && (
|
|
||||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
|
||||||
)}
|
|
||||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
|
||||||
<div>{t('mcpServers.config.url')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
|
|
||||||
)}
|
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
|
||||||
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test Results */}
|
|
||||||
{testResults?.[server.id] && (
|
|
||||||
<div className={`mt-2 p-2 rounded text-xs ${
|
|
||||||
testResults[server.id].success
|
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
|
||||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
|
||||||
}`}>
|
|
||||||
<div className="font-medium">{testResults[server.id].message}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tools Discovery Results */}
|
|
||||||
{serverTools?.[server.id] && serverTools[server.id].tools?.length > 0 && (
|
|
||||||
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
|
|
||||||
<div className="font-medium">{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: serverTools[server.id].tools.length })}</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{serverTools[server.id].tools.slice(0, 5).map((tool, i) => (
|
|
||||||
<code key={i} className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
|
|
||||||
))}
|
|
||||||
{serverTools[server.id].tools.length > 5 && (
|
|
||||||
<span className="text-xs opacity-75">{t('mcpServers.tools.more', { count: serverTools[server.id].tools.length - 5 })}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600 hover:text-gray-700"
|
|
||||||
title={t('mcpServers.actions.edit')}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => onDelete(server.id, server.scope)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
title={t('mcpServers.actions.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
{t('mcpServers.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor MCP Servers
|
|
||||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="w-5 h-5 text-purple-500" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
|
||||||
{t('mcpServers.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcpServers.description.cursor')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
onClick={onAdd}
|
|
||||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map(server => (
|
|
||||||
<div key={server.name || server.id} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{server.config?.command && (
|
|
||||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600 hover:text-gray-700"
|
|
||||||
title={t('mcpServers.actions.edit')}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => onDelete(server.name)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
title={t('mcpServers.actions.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
{t('mcpServers.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codex MCP Servers
|
|
||||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
|
||||||
{t('mcpServers.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('mcpServers.description.codex')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
onClick={onAdd}
|
|
||||||
className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map(server => (
|
|
||||||
<div key={server.name} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Terminal className="w-4 h-4" />
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
{server.config?.command && (
|
|
||||||
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
|
|
||||||
)}
|
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
|
||||||
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
|
|
||||||
)}
|
|
||||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
|
||||||
<div>{t('mcpServers.config.environment')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(server)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-600 hover:text-gray-700"
|
|
||||||
title={t('mcpServers.actions.edit')}
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => onDelete(server.name)}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
title={t('mcpServers.actions.delete')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
{t('mcpServers.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help Section */}
|
|
||||||
<div className="bg-gray-100 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
|
||||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">{t('mcpServers.help.title')}</h4>
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
{t('mcpServers.help.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main component
|
|
||||||
export default function McpServersContent({ agent, ...props }) {
|
|
||||||
if (agent === 'claude') {
|
|
||||||
return <ClaudeMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
if (agent === 'cursor') {
|
|
||||||
return <CursorMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
if (agent === 'codex') {
|
|
||||||
return <CodexMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
251
src/components/settings/Settings.tsx
Normal file
251
src/components/settings/Settings.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CredentialsSettings from '../CredentialsSettings';
|
||||||
|
import GitSettings from '../GitSettings';
|
||||||
|
import LoginModal from '../LoginModal';
|
||||||
|
import TasksSettings from '../TasksSettings';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import ClaudeMcpFormModal from './view/modals/ClaudeMcpFormModal';
|
||||||
|
import CodexMcpFormModal from './view/modals/CodexMcpFormModal';
|
||||||
|
import SettingsMainTabs from './view/SettingsMainTabs';
|
||||||
|
import AgentsSettingsTab from './view/tabs/agents-settings/AgentsSettingsTab';
|
||||||
|
import AppearanceSettingsTab from './view/tabs/AppearanceSettingsTab';
|
||||||
|
import { useSettingsController } from './hooks/useSettingsController';
|
||||||
|
import type { AgentProvider, SettingsProject, SettingsProps } from './types/types';
|
||||||
|
|
||||||
|
type LoginModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
provider: AgentProvider | '';
|
||||||
|
project: SettingsProject | null;
|
||||||
|
onComplete: (exitCode: number) => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginModalComponent = LoginModal as unknown as (props: LoginModalProps) => JSX.Element;
|
||||||
|
|
||||||
|
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
isDarkMode,
|
||||||
|
toggleDarkMode,
|
||||||
|
isSaving,
|
||||||
|
saveStatus,
|
||||||
|
projectSortOrder,
|
||||||
|
setProjectSortOrder,
|
||||||
|
codeEditorSettings,
|
||||||
|
updateCodeEditorSetting,
|
||||||
|
claudePermissions,
|
||||||
|
setClaudePermissions,
|
||||||
|
cursorPermissions,
|
||||||
|
setCursorPermissions,
|
||||||
|
codexPermissionMode,
|
||||||
|
setCodexPermissionMode,
|
||||||
|
mcpServers,
|
||||||
|
cursorMcpServers,
|
||||||
|
codexMcpServers,
|
||||||
|
mcpTestResults,
|
||||||
|
mcpServerTools,
|
||||||
|
mcpToolsLoading,
|
||||||
|
showMcpForm,
|
||||||
|
editingMcpServer,
|
||||||
|
openMcpForm,
|
||||||
|
closeMcpForm,
|
||||||
|
submitMcpForm,
|
||||||
|
handleMcpDelete,
|
||||||
|
handleMcpTest,
|
||||||
|
handleMcpToolsDiscovery,
|
||||||
|
showCodexMcpForm,
|
||||||
|
editingCodexMcpServer,
|
||||||
|
openCodexMcpForm,
|
||||||
|
closeCodexMcpForm,
|
||||||
|
submitCodexMcpForm,
|
||||||
|
handleCodexMcpDelete,
|
||||||
|
claudeAuthStatus,
|
||||||
|
cursorAuthStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
openLoginForProvider,
|
||||||
|
showLoginModal,
|
||||||
|
setShowLoginModal,
|
||||||
|
loginProvider,
|
||||||
|
selectedProject,
|
||||||
|
handleLoginComplete,
|
||||||
|
saveSettings,
|
||||||
|
} = useSettingsController({
|
||||||
|
isOpen,
|
||||||
|
initialTab,
|
||||||
|
projects,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = loginProvider === 'claude'
|
||||||
|
? claudeAuthStatus.authenticated
|
||||||
|
: loginProvider === 'cursor'
|
||||||
|
? cursorAuthStatus.authenticated
|
||||||
|
: loginProvider === 'codex'
|
||||||
|
? codexAuthStatus.authenticated
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[9999] md:p-4 bg-background/95">
|
||||||
|
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SettingsIcon className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||||
|
<h2 className="text-lg md:text-xl font-semibold text-foreground">{t('title')}</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground hover:text-foreground touch-manipulation"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<SettingsMainTabs activeTab={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
<div className="p-4 md:p-6 space-y-6 md:space-y-8 pb-safe-area-inset-bottom">
|
||||||
|
{activeTab === 'appearance' && (
|
||||||
|
<AppearanceSettingsTab
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
onToggleDarkMode={toggleDarkMode}
|
||||||
|
projectSortOrder={projectSortOrder}
|
||||||
|
onProjectSortOrderChange={setProjectSortOrder}
|
||||||
|
codeEditorSettings={codeEditorSettings}
|
||||||
|
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
|
||||||
|
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
||||||
|
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
||||||
|
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
||||||
|
onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'git' && <GitSettings />}
|
||||||
|
|
||||||
|
{activeTab === 'agents' && (
|
||||||
|
<AgentsSettingsTab
|
||||||
|
claudeAuthStatus={claudeAuthStatus}
|
||||||
|
cursorAuthStatus={cursorAuthStatus}
|
||||||
|
codexAuthStatus={codexAuthStatus}
|
||||||
|
onClaudeLogin={() => openLoginForProvider('claude')}
|
||||||
|
onCursorLogin={() => openLoginForProvider('cursor')}
|
||||||
|
onCodexLogin={() => openLoginForProvider('codex')}
|
||||||
|
claudePermissions={claudePermissions}
|
||||||
|
onClaudePermissionsChange={setClaudePermissions}
|
||||||
|
cursorPermissions={cursorPermissions}
|
||||||
|
onCursorPermissionsChange={setCursorPermissions}
|
||||||
|
codexPermissionMode={codexPermissionMode}
|
||||||
|
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||||
|
mcpServers={mcpServers}
|
||||||
|
cursorMcpServers={cursorMcpServers}
|
||||||
|
codexMcpServers={codexMcpServers}
|
||||||
|
mcpTestResults={mcpTestResults}
|
||||||
|
mcpServerTools={mcpServerTools}
|
||||||
|
mcpToolsLoading={mcpToolsLoading}
|
||||||
|
onOpenMcpForm={openMcpForm}
|
||||||
|
onDeleteMcpServer={handleMcpDelete}
|
||||||
|
onTestMcpServer={handleMcpTest}
|
||||||
|
onDiscoverMcpTools={handleMcpToolsDiscovery}
|
||||||
|
onOpenCodexMcpForm={openCodexMcpForm}
|
||||||
|
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'tasks' && (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
<TasksSettings />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'api' && (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
<CredentialsSettings />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-4 md:p-6 border-t border-border flex-shrink-0 gap-3 pb-safe-area-inset-bottom">
|
||||||
|
<div className="flex items-center justify-center sm:justify-start gap-2 order-2 sm:order-1">
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<div className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{t('saveStatus.success')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-sm flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{t('saveStatus.error')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 order-1 sm:order-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="flex-1 sm:flex-none h-10 touch-manipulation"
|
||||||
|
>
|
||||||
|
{t('footerActions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={saveSettings}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="flex-1 sm:flex-none h-10 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 touch-manipulation"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
{t('saveStatus.saving')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('footerActions.save')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginModalComponent
|
||||||
|
key={loginProvider}
|
||||||
|
isOpen={showLoginModal}
|
||||||
|
onClose={() => setShowLoginModal(false)}
|
||||||
|
provider={loginProvider}
|
||||||
|
project={selectedProject}
|
||||||
|
onComplete={handleLoginComplete}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClaudeMcpFormModal
|
||||||
|
isOpen={showMcpForm}
|
||||||
|
editingServer={editingMcpServer}
|
||||||
|
projects={projects}
|
||||||
|
onClose={closeMcpForm}
|
||||||
|
onSubmit={submitMcpForm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodexMcpFormModal
|
||||||
|
isOpen={showCodexMcpForm}
|
||||||
|
editingServer={editingCodexMcpServer}
|
||||||
|
onClose={closeCodexMcpForm}
|
||||||
|
onSubmit={submitCodexMcpForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
94
src/components/settings/constants/constants.ts
Normal file
94
src/components/settings/constants/constants.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type {
|
||||||
|
AgentCategory,
|
||||||
|
AgentProvider,
|
||||||
|
AuthStatus,
|
||||||
|
ClaudeMcpFormState,
|
||||||
|
CodexMcpFormState,
|
||||||
|
CodeEditorSettingsState,
|
||||||
|
CursorPermissionsState,
|
||||||
|
McpToolsResult,
|
||||||
|
McpTestResult,
|
||||||
|
ProjectSortOrder,
|
||||||
|
SettingsMainTab,
|
||||||
|
} from '../types/types';
|
||||||
|
|
||||||
|
export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||||
|
'agents',
|
||||||
|
'appearance',
|
||||||
|
'git',
|
||||||
|
'api',
|
||||||
|
'tasks',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||||
|
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||||
|
|
||||||
|
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||||
|
export const DEFAULT_SAVE_STATUS = null;
|
||||||
|
export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
||||||
|
theme: 'dark',
|
||||||
|
wordWrap: false,
|
||||||
|
showMinimap: true,
|
||||||
|
lineNumbers: true,
|
||||||
|
fontSize: '14',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_AUTH_STATUS: AuthStatus = {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
|
||||||
|
success: false,
|
||||||
|
message: '',
|
||||||
|
details: [],
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
|
||||||
|
success: false,
|
||||||
|
tools: [],
|
||||||
|
resources: [],
|
||||||
|
prompts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
|
||||||
|
name: '',
|
||||||
|
type: 'stdio',
|
||||||
|
scope: 'user',
|
||||||
|
projectPath: '',
|
||||||
|
config: {
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
url: '',
|
||||||
|
headers: {},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
importMode: 'form',
|
||||||
|
jsonInput: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
|
||||||
|
name: '',
|
||||||
|
type: 'stdio',
|
||||||
|
config: {
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
env: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
|
||||||
|
allowedCommands: [],
|
||||||
|
disallowedCommands: [],
|
||||||
|
skipPermissions: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {
|
||||||
|
claude: '/api/cli/claude/status',
|
||||||
|
cursor: '/api/cli/cursor/status',
|
||||||
|
codex: '/api/cli/codex/status',
|
||||||
|
};
|
||||||
802
src/components/settings/hooks/useSettingsController.ts
Normal file
802
src/components/settings/hooks/useSettingsController.ts
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import {
|
||||||
|
AUTH_STATUS_ENDPOINTS,
|
||||||
|
DEFAULT_AUTH_STATUS,
|
||||||
|
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||||
|
DEFAULT_CURSOR_PERMISSIONS,
|
||||||
|
} from '../constants/constants';
|
||||||
|
import type {
|
||||||
|
AgentProvider,
|
||||||
|
AuthStatus,
|
||||||
|
ClaudeMcpFormState,
|
||||||
|
ClaudePermissionsState,
|
||||||
|
CodeEditorSettingsState,
|
||||||
|
CodexMcpFormState,
|
||||||
|
CodexPermissionMode,
|
||||||
|
CursorPermissionsState,
|
||||||
|
McpServer,
|
||||||
|
McpToolsResult,
|
||||||
|
McpTestResult,
|
||||||
|
ProjectSortOrder,
|
||||||
|
SettingsMainTab,
|
||||||
|
SettingsProject,
|
||||||
|
} from '../types/types';
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
toggleDarkMode: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseSettingsControllerArgs = {
|
||||||
|
isOpen: boolean;
|
||||||
|
initialTab: string;
|
||||||
|
projects: SettingsProject[];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusApiResponse = {
|
||||||
|
authenticated?: boolean;
|
||||||
|
email?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JsonResult = {
|
||||||
|
success?: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpReadResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
servers?: McpServer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpCliServer = {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpCliReadResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
servers?: McpCliServer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpTestResponse = {
|
||||||
|
testResult?: McpTestResult;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type McpToolsResponse = {
|
||||||
|
toolsResult?: McpToolsResult;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClaudeSettingsStorage = {
|
||||||
|
allowedTools?: string[];
|
||||||
|
disallowedTools?: string[];
|
||||||
|
skipPermissions?: boolean;
|
||||||
|
projectSortOrder?: ProjectSortOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CursorSettingsStorage = {
|
||||||
|
allowedCommands?: string[];
|
||||||
|
disallowedCommands?: string[];
|
||||||
|
skipPermissions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodexSettingsStorage = {
|
||||||
|
permissionMode?: CodexPermissionMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveLoginProvider = AgentProvider | '';
|
||||||
|
|
||||||
|
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
|
||||||
|
|
||||||
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
|
if (tab === 'tools') {
|
||||||
|
return 'agents';
|
||||||
|
}
|
||||||
|
|
||||||
|
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string => (
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseJson = <T>(value: string | null, fallback: T): T => {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
|
||||||
|
if (value === 'acceptEdits' || value === 'bypassPermissions') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
||||||
|
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
|
||||||
|
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
|
||||||
|
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
|
||||||
|
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
|
||||||
|
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
|
||||||
|
servers.map((server) => ({
|
||||||
|
id: server.name,
|
||||||
|
name: server.name,
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
scope: 'user',
|
||||||
|
config: {
|
||||||
|
command: server.command || '',
|
||||||
|
args: server.args || [],
|
||||||
|
env: server.env || {},
|
||||||
|
url: server.url || '',
|
||||||
|
headers: server.headers || {},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDefaultProject = (projects: SettingsProject[]): SettingsProject => {
|
||||||
|
if (projects.length > 0) {
|
||||||
|
return projects[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '';
|
||||||
|
return {
|
||||||
|
name: 'default',
|
||||||
|
displayName: 'default',
|
||||||
|
fullPath: cwd,
|
||||||
|
path: cwd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
|
||||||
|
|
||||||
|
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
|
||||||
|
allowedTools: [],
|
||||||
|
disallowedTools: [],
|
||||||
|
skipPermissions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEmptyCursorPermissions = (): CursorPermissionsState => ({
|
||||||
|
...DEFAULT_CURSOR_PERMISSIONS,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {
|
||||||
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||||
|
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||||
|
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
|
||||||
|
readCodeEditorSettings()
|
||||||
|
));
|
||||||
|
|
||||||
|
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissionsState>(() => (
|
||||||
|
createEmptyClaudePermissions()
|
||||||
|
));
|
||||||
|
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
|
||||||
|
createEmptyCursorPermissions()
|
||||||
|
));
|
||||||
|
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||||
|
|
||||||
|
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||||
|
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
|
||||||
|
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
|
||||||
|
const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});
|
||||||
|
const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});
|
||||||
|
const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const [showMcpForm, setShowMcpForm] = useState(false);
|
||||||
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
|
||||||
|
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
|
||||||
|
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
|
||||||
|
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
||||||
|
const [selectedProject, setSelectedProject] = useState<SettingsProject | null>(null);
|
||||||
|
|
||||||
|
const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||||
|
const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||||
|
const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);
|
||||||
|
|
||||||
|
const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {
|
||||||
|
if (provider === 'claude') {
|
||||||
|
setClaudeAuthStatus(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'cursor') {
|
||||||
|
setCursorAuthStatus(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodexAuthStatus(status);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuthStatus = useCallback(async (provider: AgentProvider) => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setAuthStatusByProvider(provider, {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
loading: false,
|
||||||
|
error: 'Failed to check authentication status',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await toResponseJson<StatusApiResponse>(response);
|
||||||
|
setAuthStatusByProvider(provider, {
|
||||||
|
authenticated: Boolean(data.authenticated),
|
||||||
|
email: data.email || null,
|
||||||
|
loading: false,
|
||||||
|
error: data.error || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking ${provider} auth status:`, error);
|
||||||
|
setAuthStatusByProvider(provider, {
|
||||||
|
authenticated: false,
|
||||||
|
email: null,
|
||||||
|
loading: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [setAuthStatusByProvider]);
|
||||||
|
|
||||||
|
const fetchCursorMcpServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/cursor/mcp');
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch Cursor MCP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
|
||||||
|
setCursorMcpServers(data.servers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Cursor MCP servers:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCodexMcpServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
|
||||||
|
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
||||||
|
if (configData.success && configData.servers) {
|
||||||
|
setCodexMcpServers(configData.servers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
|
||||||
|
if (!cliResponse.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
||||||
|
if (!cliData.success || !cliData.servers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Codex MCP servers:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchMcpServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const configResponse = await authenticatedFetch('/api/mcp/config/read');
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
||||||
|
if (configData.success && configData.servers) {
|
||||||
|
setMcpServers(configData.servers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
|
||||||
|
if (cliResponse.ok) {
|
||||||
|
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
||||||
|
if (cliData.success && cliData.servers) {
|
||||||
|
setMcpServers(mapCliServersToMcpServers(cliData.servers));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
|
||||||
|
if (!fallbackResponse.ok) {
|
||||||
|
console.error('Failed to fetch MCP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
|
||||||
|
setMcpServers(fallbackData.servers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching MCP servers:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
||||||
|
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<JsonResult>(response);
|
||||||
|
throw new Error(error.error || 'Failed to delete server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await toResponseJson<JsonResult>(response);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to delete server via Claude CLI');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveMcpServer = useCallback(
|
||||||
|
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
||||||
|
if (editingServer?.id) {
|
||||||
|
// Editing still follows the existing behavior: remove current entry and re-create it.
|
||||||
|
await deleteMcpServer(editingServer.id, editingServer.scope || 'user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authenticatedFetch('/api/mcp/cli/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: serverData.name,
|
||||||
|
type: serverData.type,
|
||||||
|
scope: serverData.scope,
|
||||||
|
projectPath: serverData.projectPath,
|
||||||
|
command: serverData.config.command,
|
||||||
|
args: serverData.config.args || [],
|
||||||
|
url: serverData.config.url,
|
||||||
|
headers: serverData.config.headers || {},
|
||||||
|
env: serverData.config.env || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<JsonResult>(response);
|
||||||
|
throw new Error(error.error || 'Failed to save server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await toResponseJson<JsonResult>(response);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to save server via Claude CLI');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteMcpServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitMcpForm = useCallback(
|
||||||
|
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
||||||
|
if (formData.importMode === 'json') {
|
||||||
|
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name,
|
||||||
|
jsonConfig: formData.jsonInput,
|
||||||
|
scope: formData.scope,
|
||||||
|
projectPath: formData.projectPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<JsonResult>(response);
|
||||||
|
throw new Error(error.error || 'Failed to add server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await toResponseJson<JsonResult>(response);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to add server via JSON');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await saveMcpServer(formData, editingServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchMcpServers();
|
||||||
|
setSaveStatus('success');
|
||||||
|
setShowMcpForm(false);
|
||||||
|
setEditingMcpServer(null);
|
||||||
|
},
|
||||||
|
[fetchMcpServers, saveMcpServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMcpDelete = useCallback(
|
||||||
|
async (serverId: string, scope = 'user') => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMcpServer(serverId, scope);
|
||||||
|
await fetchMcpServers();
|
||||||
|
setSaveStatus('success');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${getErrorMessage(error)}`);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteMcpServer, fetchMcpServers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
||||||
|
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<McpTestResponse>(response);
|
||||||
|
throw new Error(error.error || 'Failed to test server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await toResponseJson<McpTestResponse>(response);
|
||||||
|
return data.testResult || { success: false, message: 'No test result returned' };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
|
||||||
|
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<McpToolsResponse>(response);
|
||||||
|
throw new Error(error.error || 'Failed to discover tools');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await toResponseJson<McpToolsResponse>(response);
|
||||||
|
return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMcpTest = useCallback(
|
||||||
|
async (serverId: string, scope = 'user') => {
|
||||||
|
try {
|
||||||
|
setMcpTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await testMcpServer(serverId, scope);
|
||||||
|
setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
|
||||||
|
} catch (error) {
|
||||||
|
setMcpTestResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[serverId]: {
|
||||||
|
success: false,
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
details: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[testMcpServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMcpToolsDiscovery = useCallback(
|
||||||
|
async (serverId: string, scope = 'user') => {
|
||||||
|
try {
|
||||||
|
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
|
||||||
|
const result = await discoverMcpTools(serverId, scope);
|
||||||
|
setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
|
||||||
|
} catch {
|
||||||
|
setMcpServerTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[serverId]: { success: false, tools: [], resources: [], prompts: [] },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[discoverMcpTools],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
|
||||||
|
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<JsonResult>(response);
|
||||||
|
throw new Error(error.error || 'Failed to delete server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await toResponseJson<JsonResult>(response);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to delete Codex MCP server');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveCodexMcpServer = useCallback(
|
||||||
|
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
|
||||||
|
if (editingServer?.name) {
|
||||||
|
await deleteCodexMcpServer(editingServer.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: serverData.name,
|
||||||
|
command: serverData.config.command,
|
||||||
|
args: serverData.config.args || [],
|
||||||
|
env: serverData.config.env || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await toResponseJson<JsonResult>(response);
|
||||||
|
throw new Error(error.error || 'Failed to save server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await toResponseJson<JsonResult>(response);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to save Codex MCP server');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteCodexMcpServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitCodexMcpForm = useCallback(
|
||||||
|
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
|
||||||
|
await saveCodexMcpServer(formData, editingServer);
|
||||||
|
await fetchCodexMcpServers();
|
||||||
|
setSaveStatus('success');
|
||||||
|
setShowCodexMcpForm(false);
|
||||||
|
setEditingCodexMcpServer(null);
|
||||||
|
},
|
||||||
|
[fetchCodexMcpServers, saveCodexMcpServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCodexMcpDelete = useCallback(
|
||||||
|
async (serverName: string) => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCodexMcpServer(serverName);
|
||||||
|
await fetchCodexMcpServers();
|
||||||
|
setSaveStatus('success');
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${getErrorMessage(error)}`);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteCodexMcpServer, fetchCodexMcpServers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
|
||||||
|
localStorage.getItem('claude-settings'),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setClaudePermissions({
|
||||||
|
allowedTools: savedClaudeSettings.allowedTools || [],
|
||||||
|
disallowedTools: savedClaudeSettings.disallowedTools || [],
|
||||||
|
skipPermissions: Boolean(savedClaudeSettings.skipPermissions),
|
||||||
|
});
|
||||||
|
setProjectSortOrder(savedClaudeSettings.projectSortOrder === 'date' ? 'date' : 'name');
|
||||||
|
|
||||||
|
const savedCursorSettings = parseJson<CursorSettingsStorage>(
|
||||||
|
localStorage.getItem('cursor-tools-settings'),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setCursorPermissions({
|
||||||
|
allowedCommands: savedCursorSettings.allowedCommands || [],
|
||||||
|
disallowedCommands: savedCursorSettings.disallowedCommands || [],
|
||||||
|
skipPermissions: Boolean(savedCursorSettings.skipPermissions),
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedCodexSettings = parseJson<CodexSettingsStorage>(
|
||||||
|
localStorage.getItem('codex-settings'),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
fetchMcpServers(),
|
||||||
|
fetchCursorMcpServers(),
|
||||||
|
fetchCodexMcpServers(),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
setClaudePermissions(createEmptyClaudePermissions());
|
||||||
|
setCursorPermissions(createEmptyCursorPermissions());
|
||||||
|
setCodexPermissionMode('default');
|
||||||
|
setProjectSortOrder('name');
|
||||||
|
}
|
||||||
|
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
|
||||||
|
|
||||||
|
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
||||||
|
setLoginProvider(provider);
|
||||||
|
setSelectedProject(getDefaultProject(projects));
|
||||||
|
setShowLoginModal(true);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const handleLoginComplete = useCallback((exitCode: number) => {
|
||||||
|
if (exitCode !== 0 || !loginProvider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus('success');
|
||||||
|
void checkAuthStatus(loginProvider);
|
||||||
|
}, [checkAuthStatus, loginProvider]);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(() => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveStatus(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
localStorage.setItem('claude-settings', JSON.stringify({
|
||||||
|
allowedTools: claudePermissions.allowedTools,
|
||||||
|
disallowedTools: claudePermissions.disallowedTools,
|
||||||
|
skipPermissions: claudePermissions.skipPermissions,
|
||||||
|
projectSortOrder,
|
||||||
|
lastUpdated: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem('cursor-tools-settings', JSON.stringify({
|
||||||
|
allowedCommands: cursorPermissions.allowedCommands,
|
||||||
|
disallowedCommands: cursorPermissions.disallowedCommands,
|
||||||
|
skipPermissions: cursorPermissions.skipPermissions,
|
||||||
|
lastUpdated: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
localStorage.setItem('codex-settings', JSON.stringify({
|
||||||
|
permissionMode: codexPermissionMode,
|
||||||
|
lastUpdated: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSaveStatus('success');
|
||||||
|
closeTimerRef.current = window.setTimeout(() => onClose(), 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
setSaveStatus('error');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
claudePermissions.allowedTools,
|
||||||
|
claudePermissions.disallowedTools,
|
||||||
|
claudePermissions.skipPermissions,
|
||||||
|
codexPermissionMode,
|
||||||
|
cursorPermissions.allowedCommands,
|
||||||
|
cursorPermissions.disallowedCommands,
|
||||||
|
cursorPermissions.skipPermissions,
|
||||||
|
onClose,
|
||||||
|
projectSortOrder,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const updateCodeEditorSetting = useCallback(
|
||||||
|
<K extends keyof CodeEditorSettingsState>(key: K, value: CodeEditorSettingsState[K]) => {
|
||||||
|
setCodeEditorSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openMcpForm = useCallback((server?: McpServer) => {
|
||||||
|
setEditingMcpServer(server || null);
|
||||||
|
setShowMcpForm(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMcpForm = useCallback(() => {
|
||||||
|
setShowMcpForm(false);
|
||||||
|
setEditingMcpServer(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCodexMcpForm = useCallback((server?: McpServer) => {
|
||||||
|
setEditingCodexMcpServer(server || null);
|
||||||
|
setShowCodexMcpForm(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeCodexMcpForm = useCallback(() => {
|
||||||
|
setShowCodexMcpForm(false);
|
||||||
|
setEditingCodexMcpServer(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTab(normalizeMainTab(initialTab));
|
||||||
|
void loadSettings();
|
||||||
|
void checkAuthStatus('claude');
|
||||||
|
void checkAuthStatus('cursor');
|
||||||
|
void checkAuthStatus('codex');
|
||||||
|
}, [checkAuthStatus, initialTab, isOpen, loadSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||||
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
|
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
|
||||||
|
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
|
||||||
|
localStorage.setItem('codeEditorFontSize', codeEditorSettings.fontSize);
|
||||||
|
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
|
||||||
|
}, [codeEditorSettings]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
isDarkMode,
|
||||||
|
toggleDarkMode,
|
||||||
|
isSaving,
|
||||||
|
saveStatus,
|
||||||
|
projectSortOrder,
|
||||||
|
setProjectSortOrder,
|
||||||
|
codeEditorSettings,
|
||||||
|
updateCodeEditorSetting,
|
||||||
|
claudePermissions,
|
||||||
|
setClaudePermissions,
|
||||||
|
cursorPermissions,
|
||||||
|
setCursorPermissions,
|
||||||
|
codexPermissionMode,
|
||||||
|
setCodexPermissionMode,
|
||||||
|
mcpServers,
|
||||||
|
cursorMcpServers,
|
||||||
|
codexMcpServers,
|
||||||
|
mcpTestResults,
|
||||||
|
mcpServerTools,
|
||||||
|
mcpToolsLoading,
|
||||||
|
showMcpForm,
|
||||||
|
editingMcpServer,
|
||||||
|
openMcpForm,
|
||||||
|
closeMcpForm,
|
||||||
|
submitMcpForm,
|
||||||
|
handleMcpDelete,
|
||||||
|
handleMcpTest,
|
||||||
|
handleMcpToolsDiscovery,
|
||||||
|
showCodexMcpForm,
|
||||||
|
editingCodexMcpServer,
|
||||||
|
openCodexMcpForm,
|
||||||
|
closeCodexMcpForm,
|
||||||
|
submitCodexMcpForm,
|
||||||
|
handleCodexMcpDelete,
|
||||||
|
claudeAuthStatus,
|
||||||
|
cursorAuthStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
openLoginForProvider,
|
||||||
|
showLoginModal,
|
||||||
|
setShowLoginModal,
|
||||||
|
loginProvider,
|
||||||
|
selectedProject,
|
||||||
|
handleLoginComplete,
|
||||||
|
saveSettings,
|
||||||
|
};
|
||||||
|
}
|
||||||
134
src/components/settings/types/types.ts
Normal file
134
src/components/settings/types/types.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks';
|
||||||
|
export type AgentProvider = 'claude' | 'cursor' | 'codex';
|
||||||
|
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
|
export type SaveStatus = 'success' | 'error' | null;
|
||||||
|
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||||
|
export type McpImportMode = 'form' | 'json';
|
||||||
|
export type McpScope = 'user' | 'local';
|
||||||
|
export type McpTransportType = 'stdio' | 'sse' | 'http';
|
||||||
|
|
||||||
|
export type SettingsProject = {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
fullPath?: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthStatus = {
|
||||||
|
authenticated: boolean;
|
||||||
|
email: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KeyValueMap = Record<string, string>;
|
||||||
|
|
||||||
|
export type McpServerConfig = {
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: KeyValueMap;
|
||||||
|
url?: string;
|
||||||
|
headers?: KeyValueMap;
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpServer = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
scope?: string;
|
||||||
|
projectPath?: string;
|
||||||
|
config?: McpServerConfig;
|
||||||
|
raw?: unknown;
|
||||||
|
created?: string;
|
||||||
|
updated?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaudeMcpFormConfig = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: KeyValueMap;
|
||||||
|
url: string;
|
||||||
|
headers: KeyValueMap;
|
||||||
|
timeout: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaudeMcpFormState = {
|
||||||
|
name: string;
|
||||||
|
type: McpTransportType;
|
||||||
|
scope: McpScope;
|
||||||
|
projectPath: string;
|
||||||
|
config: ClaudeMcpFormConfig;
|
||||||
|
importMode: McpImportMode;
|
||||||
|
jsonInput: string;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodexMcpFormConfig = {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: KeyValueMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodexMcpFormState = {
|
||||||
|
name: string;
|
||||||
|
type: 'stdio';
|
||||||
|
config: CodexMcpFormConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpTestResult = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
details?: string[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpTool = {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpToolsResult = {
|
||||||
|
success?: boolean;
|
||||||
|
tools?: McpTool[];
|
||||||
|
resources?: unknown[];
|
||||||
|
prompts?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaudePermissionsState = {
|
||||||
|
allowedTools: string[];
|
||||||
|
disallowedTools: string[];
|
||||||
|
skipPermissions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CursorPermissionsState = {
|
||||||
|
allowedCommands: string[];
|
||||||
|
disallowedCommands: string[];
|
||||||
|
skipPermissions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodeEditorSettingsState = {
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
wordWrap: boolean;
|
||||||
|
showMinimap: boolean;
|
||||||
|
lineNumbers: boolean;
|
||||||
|
fontSize: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsStoragePayload = {
|
||||||
|
claude: ClaudePermissionsState & { projectSortOrder: ProjectSortOrder; lastUpdated: string };
|
||||||
|
cursor: CursorPermissionsState & { lastUpdated: string };
|
||||||
|
codex: { permissionMode: CodexPermissionMode; lastUpdated: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SettingsProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
projects?: SettingsProject[];
|
||||||
|
initialTab?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetState<T> = Dispatch<SetStateAction<T>>;
|
||||||
52
src/components/settings/view/SettingsMainTabs.tsx
Normal file
52
src/components/settings/view/SettingsMainTabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { GitBranch, Key } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { SettingsMainTab } from '../types/types';
|
||||||
|
|
||||||
|
type SettingsMainTabsProps = {
|
||||||
|
activeTab: SettingsMainTab;
|
||||||
|
onChange: (tab: SettingsMainTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MainTabConfig = {
|
||||||
|
id: SettingsMainTab;
|
||||||
|
labelKey: string;
|
||||||
|
icon?: typeof GitBranch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TAB_CONFIG: MainTabConfig[] = [
|
||||||
|
{ id: 'agents', labelKey: 'mainTabs.agents' },
|
||||||
|
{ id: 'appearance', labelKey: 'mainTabs.appearance' },
|
||||||
|
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||||
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
|
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<div className="flex px-4 md:px-6">
|
||||||
|
{TAB_CONFIG.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-4 h-4 inline mr-2" />}
|
||||||
|
{t(tab.labelKey)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
479
src/components/settings/view/modals/ClaudeMcpFormModal.tsx
Normal file
479
src/components/settings/view/modals/ClaudeMcpFormModal.tsx
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Input } from '../../../ui/input';
|
||||||
|
import { Button } from '../../../ui/button';
|
||||||
|
import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
|
||||||
|
import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
|
||||||
|
|
||||||
|
type ClaudeMcpFormModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
editingServer: McpServer | null;
|
||||||
|
projects: SettingsProject[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeTransportType = (value: unknown): McpTransportType => {
|
||||||
|
if (value === 'sse' || value === 'http') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'stdio';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string => (
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
);
|
||||||
|
|
||||||
|
const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
|
||||||
|
name: server.name || '',
|
||||||
|
type: getSafeTransportType(server.type),
|
||||||
|
scope: getSafeScope(server.scope),
|
||||||
|
projectPath: server.projectPath || '',
|
||||||
|
config: {
|
||||||
|
command: server.config?.command || '',
|
||||||
|
args: server.config?.args || [],
|
||||||
|
env: server.config?.env || {},
|
||||||
|
url: server.config?.url || '',
|
||||||
|
headers: server.config?.headers || {},
|
||||||
|
timeout: server.config?.timeout || 30000,
|
||||||
|
},
|
||||||
|
importMode: 'form',
|
||||||
|
jsonInput: '',
|
||||||
|
raw: server.raw,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ClaudeMcpFormModal({
|
||||||
|
isOpen,
|
||||||
|
editingServer,
|
||||||
|
projects,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: ClaudeMcpFormModalProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);
|
||||||
|
const [jsonValidationError, setJsonValidationError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isEditing = Boolean(editingServer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJsonValidationError('');
|
||||||
|
if (editingServer) {
|
||||||
|
setFormData(createFormStateFromServer(editingServer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(DEFAULT_CLAUDE_MCP_FORM);
|
||||||
|
}, [editingServer, isOpen]);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.importMode === 'json') {
|
||||||
|
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.scope === 'local' && !formData.projectPath.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.type === 'stdio') {
|
||||||
|
return Boolean(formData.config.command.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(formData.config.url.trim());
|
||||||
|
}, [formData, jsonValidationError]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(
|
||||||
|
key: K,
|
||||||
|
value: ClaudeMcpFormState['config'][K],
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
config: {
|
||||||
|
...prev.config,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJsonValidation = (value: string) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
setJsonValidationError('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
|
||||||
|
if (!parsed.type) {
|
||||||
|
setJsonValidationError(t('mcpForm.validation.missingType'));
|
||||||
|
} else if (parsed.type === 'stdio' && !parsed.command) {
|
||||||
|
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
|
||||||
|
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
|
||||||
|
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
|
||||||
|
} else {
|
||||||
|
setJsonValidationError('');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setJsonValidationError(t('mcpForm.validation.invalidJson'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(formData, editingServer);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${getErrorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[110] p-4">
|
||||||
|
<div className="bg-background border border-border rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
{isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'form' }))}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.importMode === 'form'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('mcpForm.importMode.form')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'json' }))}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.importMode === 'json'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('mcpForm.importMode.json')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && isEditing && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.scope.label')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{formData.scope === 'user' ? <Globe className="w-4 h-4" /> : <FolderOpen className="w-4 h-4" />}
|
||||||
|
<span className="text-sm">
|
||||||
|
{formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
|
||||||
|
</span>
|
||||||
|
{formData.scope === 'local' && formData.projectPath && (
|
||||||
|
<span className="text-xs text-muted-foreground">- {formData.projectPath}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">{t('mcpForm.scope.cannotChange')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && !isEditing && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.scope.label')} *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.scope === 'user'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span>{t('mcpForm.scope.userGlobal')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.scope === 'local'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
<span>{t('mcpForm.scope.projectLocal')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{formData.scope === 'user'
|
||||||
|
? t('mcpForm.scope.userDescription')
|
||||||
|
: t('mcpForm.scope.projectDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.scope === 'local' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.selectProject')} *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.projectPath}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFormData((prev) => ({ ...prev, projectPath: event.target.value }));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">{t('mcpForm.fields.selectProject')}...</option>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.name} value={project.path || project.fullPath}>
|
||||||
|
{project.displayName || project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{formData.projectPath && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t('mcpForm.projectPath', { path: formData.projectPath })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.serverName')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder={t('mcpForm.placeholders.serverName')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.transportType')} *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: getSafeTransportType(event.target.value),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="stdio">stdio</option>
|
||||||
|
<option value="sse">SSE</option>
|
||||||
|
<option value="http">HTTP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.configDetails', {
|
||||||
|
configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
|
||||||
|
{JSON.stringify(formData.raw, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'json' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.jsonConfig')} *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.jsonInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setFormData((prev) => ({ ...prev, jsonInput: value }));
|
||||||
|
handleJsonValidation(value);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-2 border ${
|
||||||
|
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||||
|
} bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 font-mono text-sm`}
|
||||||
|
rows={8}
|
||||||
|
placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{jsonValidationError && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">{jsonValidationError}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{t('mcpForm.validation.jsonHelp')}
|
||||||
|
<br />
|
||||||
|
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
|
||||||
|
<br />
|
||||||
|
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && formData.type === 'stdio' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.command')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.config.command}
|
||||||
|
onChange={(event) => updateConfig('command', event.target.value)}
|
||||||
|
placeholder="/path/to/mcp-server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.arguments')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.config.args.join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
||||||
|
updateConfig('args', args);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows={3}
|
||||||
|
placeholder="--api-key abc123"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.url')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.config.url}
|
||||||
|
onChange={(event) => updateConfig('url', event.target.value)}
|
||||||
|
placeholder="https://api.example.com/mcp"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.envVars')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
event.target.value.split('\n').forEach((line) => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && key.trim()) {
|
||||||
|
env[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateConfig('env', env);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows={3}
|
||||||
|
placeholder="API_KEY=your-key DEBUG=true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.headers')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
event.target.value.split('\n').forEach((line) => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && key.trim()) {
|
||||||
|
headers[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateConfig('headers', headers);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t('mcpForm.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !canSubmit}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t('mcpForm.actions.saving')
|
||||||
|
: isEditing
|
||||||
|
? t('mcpForm.actions.updateServer')
|
||||||
|
: t('mcpForm.actions.addServer')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/settings/view/modals/CodexMcpFormModal.tsx
Normal file
178
src/components/settings/view/modals/CodexMcpFormModal.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '../../../ui/button';
|
||||||
|
import { Input } from '../../../ui/input';
|
||||||
|
import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
|
||||||
|
import type { CodexMcpFormState, McpServer } from '../../types/types';
|
||||||
|
|
||||||
|
type CodexMcpFormModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
editingServer: McpServer | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown): string => (
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
);
|
||||||
|
|
||||||
|
const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
|
||||||
|
name: server.name || '',
|
||||||
|
type: 'stdio',
|
||||||
|
config: {
|
||||||
|
command: server.config?.command || '',
|
||||||
|
args: server.config?.args || [],
|
||||||
|
env: server.config?.env || {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CodexMcpFormModal({
|
||||||
|
isOpen,
|
||||||
|
editingServer,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: CodexMcpFormModalProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingServer) {
|
||||||
|
setFormData(createFormStateFromServer(editingServer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(DEFAULT_CODEX_MCP_FORM);
|
||||||
|
}, [editingServer, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(formData, editingServer);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${getErrorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[110] p-4">
|
||||||
|
<div className="bg-background border border-border rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
|
{editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.serverName')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
|
||||||
|
placeholder={t('mcpForm.placeholders.serverName')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.command')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.config.command}
|
||||||
|
onChange={(event) => {
|
||||||
|
const command = event.target.value;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
config: { ...prev.config, command },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="npx @my-org/mcp-server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.arguments')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.config.args.join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
config: { ...prev.config, args },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="--port 3000"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
{t('mcpForm.fields.envVars')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||||
|
onChange={(event) => {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
event.target.value.split('\n').forEach((line) => {
|
||||||
|
const [key, ...valueParts] = line.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
env[key.trim()] = valueParts.join('=').trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
config: { ...prev.config, env },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="API_KEY=xxx DEBUG=true"
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t('mcpForm.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t('mcpForm.actions.saving')
|
||||||
|
: editingServer
|
||||||
|
? t('mcpForm.actions.updateServer')
|
||||||
|
: t('mcpForm.actions.addServer')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/components/settings/view/tabs/AppearanceSettingsTab.tsx
Normal file
189
src/components/settings/view/tabs/AppearanceSettingsTab.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LanguageSelector from '../../../LanguageSelector';
|
||||||
|
import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
|
||||||
|
|
||||||
|
type AppearanceSettingsTabProps = {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
onToggleDarkMode: () => void;
|
||||||
|
projectSortOrder: ProjectSortOrder;
|
||||||
|
onProjectSortOrderChange: (value: ProjectSortOrder) => void;
|
||||||
|
codeEditorSettings: CodeEditorSettingsState;
|
||||||
|
onCodeEditorThemeChange: (value: 'dark' | 'light') => void;
|
||||||
|
onCodeEditorWordWrapChange: (value: boolean) => void;
|
||||||
|
onCodeEditorShowMinimapChange: (value: boolean) => void;
|
||||||
|
onCodeEditorLineNumbersChange: (value: boolean) => void;
|
||||||
|
onCodeEditorFontSizeChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToggleCardProps = {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
onIcon?: ReactNode;
|
||||||
|
offIcon?: ReactNode;
|
||||||
|
ariaLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToggleCard({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
onIcon,
|
||||||
|
offIcon,
|
||||||
|
ariaLabel,
|
||||||
|
}: ToggleCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">{label}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{ariaLabel}</span>
|
||||||
|
<span
|
||||||
|
className={`${
|
||||||
|
checked ? 'translate-x-7' : 'translate-x-1'
|
||||||
|
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
{checked ? onIcon : offIcon}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppearanceSettingsTab({
|
||||||
|
isDarkMode,
|
||||||
|
onToggleDarkMode,
|
||||||
|
projectSortOrder,
|
||||||
|
onProjectSortOrderChange,
|
||||||
|
codeEditorSettings,
|
||||||
|
onCodeEditorThemeChange,
|
||||||
|
onCodeEditorWordWrapChange,
|
||||||
|
onCodeEditorShowMinimapChange,
|
||||||
|
onCodeEditorLineNumbersChange,
|
||||||
|
onCodeEditorFontSizeChange,
|
||||||
|
}: AppearanceSettingsTabProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 md:space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ToggleCard
|
||||||
|
label={t('appearanceSettings.darkMode.label')}
|
||||||
|
description={t('appearanceSettings.darkMode.description')}
|
||||||
|
checked={isDarkMode}
|
||||||
|
onChange={onToggleDarkMode}
|
||||||
|
onIcon={<Moon className="w-3.5 h-3.5 text-gray-700" />}
|
||||||
|
offIcon={<Sun className="w-3.5 h-3.5 text-yellow-500" />}
|
||||||
|
ariaLabel={t('appearanceSettings.darkMode.label')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{t('appearanceSettings.projectSorting.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('appearanceSettings.projectSorting.description')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={projectSortOrder}
|
||||||
|
onChange={(event) => onProjectSortOrderChange(event.target.value as ProjectSortOrder)}
|
||||||
|
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
|
||||||
|
>
|
||||||
|
<option value="name">{t('appearanceSettings.projectSorting.alphabetical')}</option>
|
||||||
|
<option value="date">{t('appearanceSettings.projectSorting.recentActivity')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">{t('appearanceSettings.codeEditor.title')}</h3>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
label={t('appearanceSettings.codeEditor.theme.label')}
|
||||||
|
description={t('appearanceSettings.codeEditor.theme.description')}
|
||||||
|
checked={codeEditorSettings.theme === 'dark'}
|
||||||
|
onChange={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
|
||||||
|
onIcon={<Moon className="w-3.5 h-3.5 text-gray-700" />}
|
||||||
|
offIcon={<Sun className="w-3.5 h-3.5 text-yellow-500" />}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
label={t('appearanceSettings.codeEditor.wordWrap.label')}
|
||||||
|
description={t('appearanceSettings.codeEditor.wordWrap.description')}
|
||||||
|
checked={codeEditorSettings.wordWrap}
|
||||||
|
onChange={onCodeEditorWordWrapChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
label={t('appearanceSettings.codeEditor.showMinimap.label')}
|
||||||
|
description={t('appearanceSettings.codeEditor.showMinimap.description')}
|
||||||
|
checked={codeEditorSettings.showMinimap}
|
||||||
|
onChange={onCodeEditorShowMinimapChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
label={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
||||||
|
description={t('appearanceSettings.codeEditor.lineNumbers.description')}
|
||||||
|
checked={codeEditorSettings.lineNumbers}
|
||||||
|
onChange={onCodeEditorLineNumbersChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{t('appearanceSettings.codeEditor.fontSize.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t('appearanceSettings.codeEditor.fontSize.description')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={codeEditorSettings.fontSize}
|
||||||
|
onChange={(event) => onCodeEditorFontSizeChange(event.target.value)}
|
||||||
|
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-24"
|
||||||
|
>
|
||||||
|
<option value="10">10px</option>
|
||||||
|
<option value="11">11px</option>
|
||||||
|
<option value="12">12px</option>
|
||||||
|
<option value="13">13px</option>
|
||||||
|
<option value="14">14px</option>
|
||||||
|
<option value="15">15px</option>
|
||||||
|
<option value="16">16px</option>
|
||||||
|
<option value="18">18px</option>
|
||||||
|
<option value="20">20px</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import { Button } from '../ui/button';
|
|
||||||
import { Badge } from '../ui/badge';
|
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
import SessionProviderLogo from '../SessionProviderLogo';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '../../../../ui/badge';
|
||||||
|
import { Button } from '../../../../ui/button';
|
||||||
|
import SessionProviderLogo from '../../../../SessionProviderLogo';
|
||||||
|
import type { AgentProvider, AuthStatus } from '../../../types/types';
|
||||||
|
|
||||||
const agentConfig = {
|
type AccountContentProps = {
|
||||||
|
agent: AgentProvider;
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
onLogin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentVisualConfig = {
|
||||||
|
name: string;
|
||||||
|
bgClass: string;
|
||||||
|
borderClass: string;
|
||||||
|
textClass: string;
|
||||||
|
subtextClass: string;
|
||||||
|
buttonClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
||||||
claude: {
|
claude: {
|
||||||
name: 'Claude',
|
name: 'Claude',
|
||||||
description: 'Anthropic Claude AI assistant',
|
|
||||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
borderClass: 'border-blue-200 dark:border-blue-800',
|
borderClass: 'border-blue-200 dark:border-blue-800',
|
||||||
textClass: 'text-blue-900 dark:text-blue-100',
|
textClass: 'text-blue-900 dark:text-blue-100',
|
||||||
@@ -16,7 +31,6 @@ const agentConfig = {
|
|||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
name: 'Cursor',
|
name: 'Cursor',
|
||||||
description: 'Cursor AI-powered code editor',
|
|
||||||
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
|
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
|
||||||
borderClass: 'border-purple-200 dark:border-purple-800',
|
borderClass: 'border-purple-200 dark:border-purple-800',
|
||||||
textClass: 'text-purple-900 dark:text-purple-100',
|
textClass: 'text-purple-900 dark:text-purple-100',
|
||||||
@@ -25,7 +39,6 @@ const agentConfig = {
|
|||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
name: 'Codex',
|
name: 'Codex',
|
||||||
description: 'OpenAI Codex AI assistant',
|
|
||||||
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
|
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
|
||||||
borderClass: 'border-gray-300 dark:border-gray-600',
|
borderClass: 'border-gray-300 dark:border-gray-600',
|
||||||
textClass: 'text-gray-900 dark:text-gray-100',
|
textClass: 'text-gray-900 dark:text-gray-100',
|
||||||
@@ -34,7 +47,7 @@ const agentConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccountContent({ agent, authStatus, onLogin }) {
|
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const config = agentConfig[agent];
|
const config = agentConfig[agent];
|
||||||
|
|
||||||
@@ -50,29 +63,30 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
|
|||||||
|
|
||||||
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Connection Status */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`font-medium ${config.textClass}`}>
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
{t('agents.connectionStatus')}
|
{t('agents.connectionStatus')}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-sm ${config.subtextClass}`}>
|
<div className={`text-sm ${config.subtextClass}`}>
|
||||||
{authStatus?.loading ? (
|
{authStatus.loading ? (
|
||||||
t('agents.authStatus.checkingAuth')
|
t('agents.authStatus.checkingAuth')
|
||||||
) : authStatus?.authenticated ? (
|
) : authStatus.authenticated ? (
|
||||||
t('agents.authStatus.loggedInAs', { email: authStatus.email || t('agents.authStatus.authenticatedUser') })
|
t('agents.authStatus.loggedInAs', {
|
||||||
|
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
t('agents.authStatus.notConnected')
|
t('agents.authStatus.notConnected')
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{authStatus?.loading ? (
|
{authStatus.loading ? (
|
||||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
||||||
{t('agents.authStatus.checking')}
|
{t('agents.authStatus.checking')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : authStatus?.authenticated ? (
|
) : authStatus.authenticated ? (
|
||||||
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
{t('agents.authStatus.connected')}
|
{t('agents.authStatus.connected')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
@@ -87,10 +101,10 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className={`font-medium ${config.textClass}`}>
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
{authStatus?.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-sm ${config.subtextClass}`}>
|
<div className={`text-sm ${config.subtextClass}`}>
|
||||||
{authStatus?.authenticated
|
{authStatus.authenticated
|
||||||
? t('agents.login.reAuthDescription')
|
? t('agents.login.reAuthDescription')
|
||||||
: t('agents.login.description', { agent: config.name })}
|
: t('agents.login.description', { agent: config.name })}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,12 +115,12 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
<LogIn className="w-4 h-4 mr-2" />
|
||||||
{authStatus?.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authStatus?.error && (
|
{authStatus.error && (
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
<div className="text-sm text-red-600 dark:text-red-400">
|
<div className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t('agents.error', { error: authStatus.error })}
|
{t('agents.error', { error: authStatus.error })}
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import SessionProviderLogo from '../SessionProviderLogo';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import SessionProviderLogo from '../../../../SessionProviderLogo';
|
||||||
|
import type { AgentProvider, AuthStatus } from '../../../types/types';
|
||||||
|
|
||||||
const agentConfig = {
|
type AgentListItemProps = {
|
||||||
|
agentId: AgentProvider;
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentConfig = {
|
||||||
|
name: string;
|
||||||
|
color: 'blue' | 'purple' | 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentConfig: Record<AgentProvider, AgentConfig> = {
|
||||||
claude: {
|
claude: {
|
||||||
name: 'Claude',
|
name: 'Claude',
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
@@ -35,14 +49,19 @@ const colorClasses = {
|
|||||||
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
||||||
dot: 'bg-gray-700 dark:bg-gray-300',
|
dot: 'bg-gray-700 dark:bg-gray-300',
|
||||||
},
|
},
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
export default function AgentListItem({ agentId, authStatus, isSelected, onClick, isMobile = false }) {
|
export default function AgentListItem({
|
||||||
|
agentId,
|
||||||
|
authStatus,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
isMobile = false,
|
||||||
|
}: AgentListItemProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const config = agentConfig[agentId];
|
const config = agentConfig[agentId];
|
||||||
const colors = colorClasses[config.color];
|
const colors = colorClasses[config.color];
|
||||||
|
|
||||||
// Mobile: horizontal layout with bottom border
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -56,7 +75,7 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
|
|||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
|
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
|
||||||
<span className="text-xs font-medium text-foreground">{config.name}</span>
|
<span className="text-xs font-medium text-foreground">{config.name}</span>
|
||||||
{authStatus?.authenticated && (
|
{authStatus.authenticated && (
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +83,6 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop: vertical layout with left border
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -79,12 +97,12 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
|
|||||||
<span className="font-medium text-foreground">{config.name}</span>
|
<span className="font-medium text-foreground">{config.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground pl-6">
|
<div className="text-xs text-muted-foreground pl-6">
|
||||||
{authStatus?.loading ? (
|
{authStatus.loading ? (
|
||||||
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
|
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
|
||||||
) : authStatus?.authenticated ? (
|
) : authStatus.authenticated ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||||
<span className="truncate max-w-[120px]" title={authStatus.email}>
|
<span className="truncate max-w-[120px]" title={authStatus.email ?? undefined}>
|
||||||
{authStatus.email || t('agents.authStatus.connected')}
|
{authStatus.email || t('agents.authStatus.connected')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import AgentListItem from './AgentListItem';
|
||||||
|
import AccountContent from './AccountContent';
|
||||||
|
import McpServersContent from './McpServersContent';
|
||||||
|
import PermissionsContent from './PermissionsContent';
|
||||||
|
import type {
|
||||||
|
AgentCategory,
|
||||||
|
AgentProvider,
|
||||||
|
AuthStatus,
|
||||||
|
ClaudePermissionsState,
|
||||||
|
CodexPermissionMode,
|
||||||
|
CursorPermissionsState,
|
||||||
|
McpServer,
|
||||||
|
McpToolsResult,
|
||||||
|
McpTestResult,
|
||||||
|
} from '../../../types/types';
|
||||||
|
|
||||||
|
type AgentsSettingsTabProps = {
|
||||||
|
claudeAuthStatus: AuthStatus;
|
||||||
|
cursorAuthStatus: AuthStatus;
|
||||||
|
codexAuthStatus: AuthStatus;
|
||||||
|
onClaudeLogin: () => void;
|
||||||
|
onCursorLogin: () => void;
|
||||||
|
onCodexLogin: () => void;
|
||||||
|
claudePermissions: ClaudePermissionsState;
|
||||||
|
onClaudePermissionsChange: (value: ClaudePermissionsState) => void;
|
||||||
|
cursorPermissions: CursorPermissionsState;
|
||||||
|
onCursorPermissionsChange: (value: CursorPermissionsState) => void;
|
||||||
|
codexPermissionMode: CodexPermissionMode;
|
||||||
|
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
|
mcpServers: McpServer[];
|
||||||
|
cursorMcpServers: McpServer[];
|
||||||
|
codexMcpServers: McpServer[];
|
||||||
|
mcpTestResults: Record<string, McpTestResult>;
|
||||||
|
mcpServerTools: Record<string, McpToolsResult>;
|
||||||
|
mcpToolsLoading: Record<string, boolean>;
|
||||||
|
onOpenMcpForm: (server?: McpServer) => void;
|
||||||
|
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
||||||
|
onTestMcpServer: (serverId: string, scope?: string) => void;
|
||||||
|
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
|
||||||
|
onOpenCodexMcpForm: (server?: McpServer) => void;
|
||||||
|
onDeleteCodexMcpServer: (serverId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentContext = {
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
onLogin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgentsSettingsTab({
|
||||||
|
claudeAuthStatus,
|
||||||
|
cursorAuthStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
onClaudeLogin,
|
||||||
|
onCursorLogin,
|
||||||
|
onCodexLogin,
|
||||||
|
claudePermissions,
|
||||||
|
onClaudePermissionsChange,
|
||||||
|
cursorPermissions,
|
||||||
|
onCursorPermissionsChange,
|
||||||
|
codexPermissionMode,
|
||||||
|
onCodexPermissionModeChange,
|
||||||
|
mcpServers,
|
||||||
|
cursorMcpServers,
|
||||||
|
codexMcpServers,
|
||||||
|
mcpTestResults,
|
||||||
|
mcpServerTools,
|
||||||
|
mcpToolsLoading,
|
||||||
|
onOpenMcpForm,
|
||||||
|
onDeleteMcpServer,
|
||||||
|
onTestMcpServer,
|
||||||
|
onDiscoverMcpTools,
|
||||||
|
onOpenCodexMcpForm,
|
||||||
|
onDeleteCodexMcpServer,
|
||||||
|
}: AgentsSettingsTabProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
||||||
|
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
|
||||||
|
const noopCursorMcpAction = () => {};
|
||||||
|
|
||||||
|
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||||
|
claude: {
|
||||||
|
authStatus: claudeAuthStatus,
|
||||||
|
onLogin: onClaudeLogin,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
authStatus: cursorAuthStatus,
|
||||||
|
onLogin: onCursorLogin,
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
authStatus: codexAuthStatus,
|
||||||
|
onLogin: onCodexLogin,
|
||||||
|
},
|
||||||
|
}), [
|
||||||
|
claudeAuthStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
cursorAuthStatus,
|
||||||
|
onClaudeLogin,
|
||||||
|
onCodexLogin,
|
||||||
|
onCursorLogin,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row h-full min-h-[400px] md:min-h-[500px]">
|
||||||
|
<div className="md:hidden border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
|
<div className="flex">
|
||||||
|
{(['claude', 'cursor', 'codex'] as AgentProvider[]).map((agent) => (
|
||||||
|
<AgentListItem
|
||||||
|
key={`mobile-${agent}`}
|
||||||
|
agentId={agent}
|
||||||
|
authStatus={agentContextById[agent].authStatus}
|
||||||
|
isSelected={selectedAgent === agent}
|
||||||
|
onClick={() => setSelectedAgent(agent)}
|
||||||
|
isMobile
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block w-48 border-r border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
|
<div className="p-2">
|
||||||
|
{(['claude', 'cursor', 'codex'] as AgentProvider[]).map((agent) => (
|
||||||
|
<AgentListItem
|
||||||
|
key={`desktop-${agent}`}
|
||||||
|
agentId={agent}
|
||||||
|
authStatus={agentContextById[agent].authStatus}
|
||||||
|
isSelected={selectedAgent === agent}
|
||||||
|
onClick={() => setSelectedAgent(agent)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
|
<div className="flex px-2 md:px-4 overflow-x-auto">
|
||||||
|
{(['account', 'permissions', 'mcp'] as AgentCategory[]).map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => setSelectedCategory(category)}
|
||||||
|
className={`px-3 md:px-4 py-2 md:py-3 text-xs md:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
|
selectedCategory === category
|
||||||
|
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category === 'account' && t('tabs.account')}
|
||||||
|
{category === 'permissions' && t('tabs.permissions')}
|
||||||
|
{category === 'mcp' && t('tabs.mcpServers')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 md:p-4">
|
||||||
|
{selectedCategory === 'account' && (
|
||||||
|
<AccountContent
|
||||||
|
agent={selectedAgent}
|
||||||
|
authStatus={agentContextById[selectedAgent].authStatus}
|
||||||
|
onLogin={agentContextById[selectedAgent].onLogin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'permissions' && selectedAgent === 'claude' && (
|
||||||
|
<PermissionsContent
|
||||||
|
agent="claude"
|
||||||
|
skipPermissions={claudePermissions.skipPermissions}
|
||||||
|
onSkipPermissionsChange={(value) => {
|
||||||
|
onClaudePermissionsChange({ ...claudePermissions, skipPermissions: value });
|
||||||
|
}}
|
||||||
|
allowedTools={claudePermissions.allowedTools}
|
||||||
|
onAllowedToolsChange={(value) => {
|
||||||
|
onClaudePermissionsChange({ ...claudePermissions, allowedTools: value });
|
||||||
|
}}
|
||||||
|
disallowedTools={claudePermissions.disallowedTools}
|
||||||
|
onDisallowedToolsChange={(value) => {
|
||||||
|
onClaudePermissionsChange({ ...claudePermissions, disallowedTools: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'permissions' && selectedAgent === 'cursor' && (
|
||||||
|
<PermissionsContent
|
||||||
|
agent="cursor"
|
||||||
|
skipPermissions={cursorPermissions.skipPermissions}
|
||||||
|
onSkipPermissionsChange={(value) => {
|
||||||
|
onCursorPermissionsChange({ ...cursorPermissions, skipPermissions: value });
|
||||||
|
}}
|
||||||
|
allowedCommands={cursorPermissions.allowedCommands}
|
||||||
|
onAllowedCommandsChange={(value) => {
|
||||||
|
onCursorPermissionsChange({ ...cursorPermissions, allowedCommands: value });
|
||||||
|
}}
|
||||||
|
disallowedCommands={cursorPermissions.disallowedCommands}
|
||||||
|
onDisallowedCommandsChange={(value) => {
|
||||||
|
onCursorPermissionsChange({ ...cursorPermissions, disallowedCommands: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'permissions' && selectedAgent === 'codex' && (
|
||||||
|
<PermissionsContent
|
||||||
|
agent="codex"
|
||||||
|
permissionMode={codexPermissionMode}
|
||||||
|
onPermissionModeChange={onCodexPermissionModeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
|
||||||
|
<McpServersContent
|
||||||
|
agent="claude"
|
||||||
|
servers={mcpServers}
|
||||||
|
onAdd={() => onOpenMcpForm()}
|
||||||
|
onEdit={(server) => onOpenMcpForm(server)}
|
||||||
|
onDelete={onDeleteMcpServer}
|
||||||
|
onTest={onTestMcpServer}
|
||||||
|
onDiscoverTools={onDiscoverMcpTools}
|
||||||
|
testResults={mcpTestResults}
|
||||||
|
serverTools={mcpServerTools}
|
||||||
|
toolsLoading={mcpToolsLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
|
||||||
|
<McpServersContent
|
||||||
|
agent="cursor"
|
||||||
|
servers={cursorMcpServers}
|
||||||
|
onAdd={noopCursorMcpAction}
|
||||||
|
onEdit={noopCursorMcpAction}
|
||||||
|
onDelete={noopCursorMcpAction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
|
||||||
|
<McpServersContent
|
||||||
|
agent="codex"
|
||||||
|
servers={codexMcpServers}
|
||||||
|
onAdd={() => onOpenCodexMcpForm()}
|
||||||
|
onEdit={(server) => onOpenCodexMcpForm(server)}
|
||||||
|
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '../../../../ui/badge';
|
||||||
|
import { Button } from '../../../../ui/button';
|
||||||
|
import type { McpServer, McpToolsResult, McpTestResult } from '../../../types/types';
|
||||||
|
|
||||||
|
const getTransportIcon = (type: string | undefined) => {
|
||||||
|
if (type === 'stdio') {
|
||||||
|
return <Terminal className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'sse') {
|
||||||
|
return <Zap className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'http') {
|
||||||
|
return <Globe className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Server className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClaudeMcpServersProps = {
|
||||||
|
agent: 'claude';
|
||||||
|
servers: McpServer[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (server: McpServer) => void;
|
||||||
|
onDelete: (serverId: string, scope?: string) => void;
|
||||||
|
onTest: (serverId: string, scope?: string) => void;
|
||||||
|
onDiscoverTools: (serverId: string, scope?: string) => void;
|
||||||
|
testResults: Record<string, McpTestResult>;
|
||||||
|
serverTools: Record<string, McpToolsResult>;
|
||||||
|
toolsLoading: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ClaudeMcpServers({
|
||||||
|
servers,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
testResults,
|
||||||
|
serverTools,
|
||||||
|
}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('mcpServers.description.claude')}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button onClick={onAdd} className="bg-purple-600 hover:bg-purple-700 text-white" size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{t('mcpServers.addButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{servers.map((server) => {
|
||||||
|
const serverId = server.id || server.name;
|
||||||
|
const testResult = testResults[serverId];
|
||||||
|
const toolsResult = serverTools[serverId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={serverId} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{getTransportIcon(server.type)}
|
||||||
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.type || 'stdio'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{server.scope === 'local'
|
||||||
|
? t('mcpServers.scope.local')
|
||||||
|
: server.scope === 'user'
|
||||||
|
? t('mcpServers.scope.user')
|
||||||
|
: server.scope}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
{server.type === 'stdio' && server.config?.command && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.command')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.url')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.config?.args && server.config.args.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.args')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className={`mt-2 p-2 rounded text-xs ${
|
||||||
|
testResult.success
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{testResult.message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
|
||||||
|
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{toolsResult.tools.slice(0, 5).map((tool, index) => (
|
||||||
|
<code key={`${tool.name}-${index}`} className="bg-blue-100 dark:bg-blue-800 px-1 rounded">
|
||||||
|
{tool.name}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
{toolsResult.tools.length > 5 && (
|
||||||
|
<span className="text-xs opacity-75">
|
||||||
|
{t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => onEdit(server)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-gray-600 hover:text-gray-700"
|
||||||
|
title={t('mcpServers.actions.edit')}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(serverId, server.scope)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
title={t('mcpServers.actions.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CursorMcpServersProps = {
|
||||||
|
agent: 'cursor';
|
||||||
|
servers: McpServer[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (server: McpServer) => void;
|
||||||
|
onDelete: (serverId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-5 h-5 text-purple-500" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('mcpServers.description.cursor')}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button onClick={onAdd} className="bg-purple-600 hover:bg-purple-700 text-white" size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{t('mcpServers.addButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{servers.map((server) => {
|
||||||
|
const serverId = server.id || server.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={serverId} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{server.config?.command && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.command')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => onEdit(server)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-gray-600 hover:text-gray-700"
|
||||||
|
title={t('mcpServers.actions.edit')}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(serverId)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
title={t('mcpServers.actions.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CodexMcpServersProps = {
|
||||||
|
agent: 'codex';
|
||||||
|
servers: McpServer[];
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (server: McpServer) => void;
|
||||||
|
onDelete: (serverId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CodexMcpServersProps, 'agent'>) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Button onClick={onAdd} className="bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600 text-white" size="sm">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{t('mcpServers.addButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<div key={server.name} className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
<span className="font-medium text-foreground">{server.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
{server.config?.command && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.command')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.config?.args && server.config.args.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.args')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
||||||
|
<div>
|
||||||
|
{t('mcpServers.config.environment')}:{' '}
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
|
||||||
|
{Object.entries(server.config.env).map(([key, value]) => `${key}=${value}`).join(', ')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => onEdit(server)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-gray-600 hover:text-gray-700"
|
||||||
|
title={t('mcpServers.actions.edit')}
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(server.name)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
title={t('mcpServers.actions.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-100 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">{t('mcpServers.help.title')}</h4>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">{t('mcpServers.help.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
|
||||||
|
|
||||||
|
export default function McpServersContent(props: McpServersContentProps) {
|
||||||
|
if (props.agent === 'claude') {
|
||||||
|
return <ClaudeMcpServers {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.agent === 'cursor') {
|
||||||
|
return <CursorMcpServers {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CodexMcpServers {...props} />;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Button } from '../ui/button';
|
import { useState } from 'react';
|
||||||
import { Input } from '../ui/input';
|
import { AlertTriangle, Plus, Shield, X } from 'lucide-react';
|
||||||
import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '../../../../ui/button';
|
||||||
|
import { Input } from '../../../../ui/input';
|
||||||
|
import type { CodexPermissionMode } from '../../../types/types';
|
||||||
|
|
||||||
// Common tool patterns for Claude
|
const COMMON_CLAUDE_TOOLS = [
|
||||||
const commonClaudeTools = [
|
|
||||||
'Bash(git log:*)',
|
'Bash(git log:*)',
|
||||||
'Bash(git diff:*)',
|
'Bash(git diff:*)',
|
||||||
'Bash(git status:*)',
|
'Bash(git status:*)',
|
||||||
@@ -18,11 +19,10 @@ const commonClaudeTools = [
|
|||||||
'TodoWrite',
|
'TodoWrite',
|
||||||
'TodoRead',
|
'TodoRead',
|
||||||
'WebFetch',
|
'WebFetch',
|
||||||
'WebSearch'
|
'WebSearch',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Common shell commands for Cursor
|
const COMMON_CURSOR_COMMANDS = [
|
||||||
const commonCursorCommands = [
|
|
||||||
'Shell(ls)',
|
'Shell(ls)',
|
||||||
'Shell(mkdir)',
|
'Shell(mkdir)',
|
||||||
'Shell(cd)',
|
'Shell(cd)',
|
||||||
@@ -34,61 +34,77 @@ const commonCursorCommands = [
|
|||||||
'Shell(npm install)',
|
'Shell(npm install)',
|
||||||
'Shell(npm run)',
|
'Shell(npm run)',
|
||||||
'Shell(python)',
|
'Shell(python)',
|
||||||
'Shell(node)'
|
'Shell(node)',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Claude Permissions
|
const addUnique = (items: string[], value: string): string[] => {
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
if (!normalizedValue || items.includes(normalizedValue)) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...items, normalizedValue];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (items: string[], value: string): string[] => (
|
||||||
|
items.filter((item) => item !== value)
|
||||||
|
);
|
||||||
|
|
||||||
|
type ClaudePermissionsProps = {
|
||||||
|
agent: 'claude';
|
||||||
|
skipPermissions: boolean;
|
||||||
|
onSkipPermissionsChange: (value: boolean) => void;
|
||||||
|
allowedTools: string[];
|
||||||
|
onAllowedToolsChange: (value: string[]) => void;
|
||||||
|
disallowedTools: string[];
|
||||||
|
onDisallowedToolsChange: (value: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function ClaudePermissions({
|
function ClaudePermissions({
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
setSkipPermissions,
|
onSkipPermissionsChange,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
setAllowedTools,
|
onAllowedToolsChange,
|
||||||
disallowedTools,
|
disallowedTools,
|
||||||
setDisallowedTools,
|
onDisallowedToolsChange,
|
||||||
newAllowedTool,
|
}: Omit<ClaudePermissionsProps, 'agent'>) {
|
||||||
setNewAllowedTool,
|
|
||||||
newDisallowedTool,
|
|
||||||
setNewDisallowedTool,
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const addAllowedTool = (tool) => {
|
const [newAllowedTool, setNewAllowedTool] = useState('');
|
||||||
if (tool && !allowedTools.includes(tool)) {
|
const [newDisallowedTool, setNewDisallowedTool] = useState('');
|
||||||
setAllowedTools([...allowedTools, tool]);
|
|
||||||
setNewAllowedTool('');
|
const handleAddAllowedTool = (tool: string) => {
|
||||||
|
const updated = addUnique(allowedTools, tool);
|
||||||
|
if (updated.length === allowedTools.length) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAllowedToolsChange(updated);
|
||||||
|
setNewAllowedTool('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAllowedTool = (tool) => {
|
const handleAddDisallowedTool = (tool: string) => {
|
||||||
setAllowedTools(allowedTools.filter(t => t !== tool));
|
const updated = addUnique(disallowedTools, tool);
|
||||||
};
|
if (updated.length === disallowedTools.length) {
|
||||||
|
return;
|
||||||
const addDisallowedTool = (tool) => {
|
|
||||||
if (tool && !disallowedTools.includes(tool)) {
|
|
||||||
setDisallowedTools([...disallowedTools, tool]);
|
|
||||||
setNewDisallowedTool('');
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const removeDisallowedTool = (tool) => {
|
onDisallowedToolsChange(updated);
|
||||||
setDisallowedTools(disallowedTools.filter(t => t !== tool));
|
setNewDisallowedTool('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Skip Permissions */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.title')}</h3>
|
||||||
{t('permissions.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
className="w-4 h-4 text-blue-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -103,34 +119,29 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Allowed Tools */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="w-5 h-5 text-green-500" />
|
<Shield className="w-5 h-5 text-green-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.allowedTools.title')}</h3>
|
||||||
{t('permissions.allowedTools.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('permissions.allowedTools.description')}</p>
|
||||||
{t('permissions.allowedTools.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newAllowedTool}
|
value={newAllowedTool}
|
||||||
onChange={(e) => setNewAllowedTool(e.target.value)}
|
onChange={(event) => setNewAllowedTool(event.target.value)}
|
||||||
placeholder={t('permissions.allowedTools.placeholder')}
|
placeholder={t('permissions.allowedTools.placeholder')}
|
||||||
onKeyPress={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
addAllowedTool(newAllowedTool);
|
handleAddAllowedTool(newAllowedTool);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 h-10"
|
className="flex-1 h-10"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addAllowedTool(newAllowedTool)}
|
onClick={() => handleAddAllowedTool(newAllowedTool)}
|
||||||
disabled={!newAllowedTool}
|
disabled={!newAllowedTool.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 px-4"
|
className="h-10 px-4"
|
||||||
>
|
>
|
||||||
@@ -139,18 +150,17 @@ function ClaudePermissions({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick add buttons */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{t('permissions.allowedTools.quickAdd')}
|
{t('permissions.allowedTools.quickAdd')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{commonClaudeTools.map(tool => (
|
{COMMON_CLAUDE_TOOLS.map((tool) => (
|
||||||
<Button
|
<Button
|
||||||
key={tool}
|
key={tool}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => addAllowedTool(tool)}
|
onClick={() => handleAddAllowedTool(tool)}
|
||||||
disabled={allowedTools.includes(tool)}
|
disabled={allowedTools.includes(tool)}
|
||||||
className="text-xs h-8"
|
className="text-xs h-8"
|
||||||
>
|
>
|
||||||
@@ -161,15 +171,13 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allowedTools.map(tool => (
|
{allowedTools.map((tool) => (
|
||||||
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
<div key={tool} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
<span className="font-mono text-sm text-green-800 dark:text-green-200">{tool}</span>
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeAllowedTool(tool)}
|
onClick={() => onAllowedToolsChange(removeValue(allowedTools, tool))}
|
||||||
className="text-green-600 hover:text-green-700"
|
className="text-green-600 hover:text-green-700"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -184,34 +192,29 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disallowed Tools */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.blockedTools.title')}</h3>
|
||||||
{t('permissions.blockedTools.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('permissions.blockedTools.description')}</p>
|
||||||
{t('permissions.blockedTools.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newDisallowedTool}
|
value={newDisallowedTool}
|
||||||
onChange={(e) => setNewDisallowedTool(e.target.value)}
|
onChange={(event) => setNewDisallowedTool(event.target.value)}
|
||||||
placeholder={t('permissions.blockedTools.placeholder')}
|
placeholder={t('permissions.blockedTools.placeholder')}
|
||||||
onKeyPress={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
addDisallowedTool(newDisallowedTool);
|
handleAddDisallowedTool(newDisallowedTool);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 h-10"
|
className="flex-1 h-10"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addDisallowedTool(newDisallowedTool)}
|
onClick={() => handleAddDisallowedTool(newDisallowedTool)}
|
||||||
disabled={!newDisallowedTool}
|
disabled={!newDisallowedTool.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 px-4"
|
className="h-10 px-4"
|
||||||
>
|
>
|
||||||
@@ -221,15 +224,13 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{disallowedTools.map(tool => (
|
{disallowedTools.map((tool) => (
|
||||||
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
<div key={tool} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
<span className="font-mono text-sm text-red-800 dark:text-red-200">{tool}</span>
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeDisallowedTool(tool)}
|
onClick={() => onDisallowedToolsChange(removeValue(disallowedTools, tool))}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -244,7 +245,6 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Section */}
|
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
{t('permissions.toolExamples.title')}
|
{t('permissions.toolExamples.title')}
|
||||||
@@ -260,58 +260,61 @@ function ClaudePermissions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor Permissions
|
type CursorPermissionsProps = {
|
||||||
|
agent: 'cursor';
|
||||||
|
skipPermissions: boolean;
|
||||||
|
onSkipPermissionsChange: (value: boolean) => void;
|
||||||
|
allowedCommands: string[];
|
||||||
|
onAllowedCommandsChange: (value: string[]) => void;
|
||||||
|
disallowedCommands: string[];
|
||||||
|
onDisallowedCommandsChange: (value: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function CursorPermissions({
|
function CursorPermissions({
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
setSkipPermissions,
|
onSkipPermissionsChange,
|
||||||
allowedCommands,
|
allowedCommands,
|
||||||
setAllowedCommands,
|
onAllowedCommandsChange,
|
||||||
disallowedCommands,
|
disallowedCommands,
|
||||||
setDisallowedCommands,
|
onDisallowedCommandsChange,
|
||||||
newAllowedCommand,
|
}: Omit<CursorPermissionsProps, 'agent'>) {
|
||||||
setNewAllowedCommand,
|
|
||||||
newDisallowedCommand,
|
|
||||||
setNewDisallowedCommand,
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const addAllowedCommand = (cmd) => {
|
const [newAllowedCommand, setNewAllowedCommand] = useState('');
|
||||||
if (cmd && !allowedCommands.includes(cmd)) {
|
const [newDisallowedCommand, setNewDisallowedCommand] = useState('');
|
||||||
setAllowedCommands([...allowedCommands, cmd]);
|
|
||||||
setNewAllowedCommand('');
|
const handleAddAllowedCommand = (command: string) => {
|
||||||
|
const updated = addUnique(allowedCommands, command);
|
||||||
|
if (updated.length === allowedCommands.length) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAllowedCommandsChange(updated);
|
||||||
|
setNewAllowedCommand('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAllowedCommand = (cmd) => {
|
const handleAddDisallowedCommand = (command: string) => {
|
||||||
setAllowedCommands(allowedCommands.filter(c => c !== cmd));
|
const updated = addUnique(disallowedCommands, command);
|
||||||
};
|
if (updated.length === disallowedCommands.length) {
|
||||||
|
return;
|
||||||
const addDisallowedCommand = (cmd) => {
|
|
||||||
if (cmd && !disallowedCommands.includes(cmd)) {
|
|
||||||
setDisallowedCommands([...disallowedCommands, cmd]);
|
|
||||||
setNewDisallowedCommand('');
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const removeDisallowedCommand = (cmd) => {
|
onDisallowedCommandsChange(updated);
|
||||||
setDisallowedCommands(disallowedCommands.filter(c => c !== cmd));
|
setNewDisallowedCommand('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Skip Permissions */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.title')}</h3>
|
||||||
{t('permissions.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onChange={(e) => setSkipPermissions(e.target.checked)}
|
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
||||||
className="w-4 h-4 text-purple-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
|
className="w-4 h-4 text-purple-600 bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-purple-500 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -326,34 +329,29 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Allowed Commands */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="w-5 h-5 text-green-500" />
|
<Shield className="w-5 h-5 text-green-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.allowedCommands.title')}</h3>
|
||||||
{t('permissions.allowedCommands.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('permissions.allowedCommands.description')}</p>
|
||||||
{t('permissions.allowedCommands.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newAllowedCommand}
|
value={newAllowedCommand}
|
||||||
onChange={(e) => setNewAllowedCommand(e.target.value)}
|
onChange={(event) => setNewAllowedCommand(event.target.value)}
|
||||||
placeholder={t('permissions.allowedCommands.placeholder')}
|
placeholder={t('permissions.allowedCommands.placeholder')}
|
||||||
onKeyPress={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
addAllowedCommand(newAllowedCommand);
|
handleAddAllowedCommand(newAllowedCommand);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 h-10"
|
className="flex-1 h-10"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addAllowedCommand(newAllowedCommand)}
|
onClick={() => handleAddAllowedCommand(newAllowedCommand)}
|
||||||
disabled={!newAllowedCommand}
|
disabled={!newAllowedCommand.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 px-4"
|
className="h-10 px-4"
|
||||||
>
|
>
|
||||||
@@ -362,37 +360,34 @@ function CursorPermissions({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick add buttons */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{t('permissions.allowedCommands.quickAdd')}
|
{t('permissions.allowedCommands.quickAdd')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{commonCursorCommands.map(cmd => (
|
{COMMON_CURSOR_COMMANDS.map((command) => (
|
||||||
<Button
|
<Button
|
||||||
key={cmd}
|
key={command}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => addAllowedCommand(cmd)}
|
onClick={() => handleAddAllowedCommand(command)}
|
||||||
disabled={allowedCommands.includes(cmd)}
|
disabled={allowedCommands.includes(command)}
|
||||||
className="text-xs h-8"
|
className="text-xs h-8"
|
||||||
>
|
>
|
||||||
{cmd}
|
{command}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allowedCommands.map(cmd => (
|
{allowedCommands.map((command) => (
|
||||||
<div key={cmd} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
<div key={command} className="flex items-center justify-between bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||||
<span className="font-mono text-sm text-green-800 dark:text-green-200">
|
<span className="font-mono text-sm text-green-800 dark:text-green-200">{command}</span>
|
||||||
{cmd}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeAllowedCommand(cmd)}
|
onClick={() => onAllowedCommandsChange(removeValue(allowedCommands, command))}
|
||||||
className="text-green-600 hover:text-green-700"
|
className="text-green-600 hover:text-green-700"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -407,34 +402,29 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Disallowed Commands */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.blockedCommands.title')}</h3>
|
||||||
{t('permissions.blockedCommands.title')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('permissions.blockedCommands.description')}</p>
|
||||||
{t('permissions.blockedCommands.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={newDisallowedCommand}
|
value={newDisallowedCommand}
|
||||||
onChange={(e) => setNewDisallowedCommand(e.target.value)}
|
onChange={(event) => setNewDisallowedCommand(event.target.value)}
|
||||||
placeholder={t('permissions.blockedCommands.placeholder')}
|
placeholder={t('permissions.blockedCommands.placeholder')}
|
||||||
onKeyPress={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
addDisallowedCommand(newDisallowedCommand);
|
handleAddDisallowedCommand(newDisallowedCommand);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 h-10"
|
className="flex-1 h-10"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => addDisallowedCommand(newDisallowedCommand)}
|
onClick={() => handleAddDisallowedCommand(newDisallowedCommand)}
|
||||||
disabled={!newDisallowedCommand}
|
disabled={!newDisallowedCommand.trim()}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 px-4"
|
className="h-10 px-4"
|
||||||
>
|
>
|
||||||
@@ -444,15 +434,13 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{disallowedCommands.map(cmd => (
|
{disallowedCommands.map((command) => (
|
||||||
<div key={cmd} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
<div key={command} className="flex items-center justify-between bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||||
<span className="font-mono text-sm text-red-800 dark:text-red-200">
|
<span className="font-mono text-sm text-red-800 dark:text-red-200">{command}</span>
|
||||||
{cmd}
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeDisallowedCommand(cmd)}
|
onClick={() => onDisallowedCommandsChange(removeValue(disallowedCommands, command))}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -467,7 +455,6 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Help Section */}
|
|
||||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||||
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
|
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
|
||||||
{t('permissions.shellExamples.title')}
|
{t('permissions.shellExamples.title')}
|
||||||
@@ -483,37 +470,38 @@ function CursorPermissions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex Permissions
|
type CodexPermissionsProps = {
|
||||||
function CodexPermissions({ permissionMode, setPermissionMode }) {
|
agent: 'codex';
|
||||||
|
permissionMode: CodexPermissionMode;
|
||||||
|
onPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<CodexPermissionsProps, 'agent'>) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="w-5 h-5 text-green-500" />
|
<Shield className="w-5 h-5 text-green-500" />
|
||||||
<h3 className="text-lg font-medium text-foreground">
|
<h3 className="text-lg font-medium text-foreground">{t('permissions.codex.permissionMode')}</h3>
|
||||||
{t('permissions.codex.permissionMode')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('permissions.codex.description')}</p>
|
||||||
{t('permissions.codex.description')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Default Mode */}
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||||
permissionMode === 'default'
|
permissionMode === 'default'
|
||||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
? 'bg-gray-100 dark:bg-gray-800 border-gray-400 dark:border-gray-500'
|
||||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setPermissionMode('default')}
|
onClick={() => onPermissionModeChange('default')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="codexPermissionMode"
|
name="codexPermissionMode"
|
||||||
checked={permissionMode === 'default'}
|
checked={permissionMode === 'default'}
|
||||||
onChange={() => setPermissionMode('default')}
|
onChange={() => onPermissionModeChange('default')}
|
||||||
className="mt-1 w-4 h-4 text-green-600"
|
className="mt-1 w-4 h-4 text-green-600"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -525,21 +513,20 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accept Edits Mode */}
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||||
permissionMode === 'acceptEdits'
|
permissionMode === 'acceptEdits'
|
||||||
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
? 'bg-green-50 dark:bg-green-900/20 border-green-400 dark:border-green-600'
|
||||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setPermissionMode('acceptEdits')}
|
onClick={() => onPermissionModeChange('acceptEdits')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="codexPermissionMode"
|
name="codexPermissionMode"
|
||||||
checked={permissionMode === 'acceptEdits'}
|
checked={permissionMode === 'acceptEdits'}
|
||||||
onChange={() => setPermissionMode('acceptEdits')}
|
onChange={() => onPermissionModeChange('acceptEdits')}
|
||||||
className="mt-1 w-4 h-4 text-green-600"
|
className="mt-1 w-4 h-4 text-green-600"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -551,21 +538,20 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bypass Permissions Mode */}
|
|
||||||
<div
|
<div
|
||||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||||
permissionMode === 'bypassPermissions'
|
permissionMode === 'bypassPermissions'
|
||||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-400 dark:border-orange-600'
|
||||||
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setPermissionMode('bypassPermissions')}
|
onClick={() => onPermissionModeChange('bypassPermissions')}
|
||||||
>
|
>
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="codexPermissionMode"
|
name="codexPermissionMode"
|
||||||
checked={permissionMode === 'bypassPermissions'}
|
checked={permissionMode === 'bypassPermissions'}
|
||||||
onChange={() => setPermissionMode('bypassPermissions')}
|
onChange={() => onPermissionModeChange('bypassPermissions')}
|
||||||
className="mt-1 w-4 h-4 text-orange-600"
|
className="mt-1 w-4 h-4 text-orange-600"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
@@ -580,7 +566,6 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Technical Details */}
|
|
||||||
<details className="text-sm">
|
<details className="text-sm">
|
||||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
{t('permissions.codex.technicalDetails')}
|
{t('permissions.codex.technicalDetails')}
|
||||||
@@ -597,16 +582,16 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component
|
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps;
|
||||||
export default function PermissionsContent({ agent, ...props }) {
|
|
||||||
if (agent === 'claude') {
|
export default function PermissionsContent(props: PermissionsContentProps) {
|
||||||
|
if (props.agent === 'claude') {
|
||||||
return <ClaudePermissions {...props} />;
|
return <ClaudePermissions {...props} />;
|
||||||
}
|
}
|
||||||
if (agent === 'cursor') {
|
|
||||||
|
if (props.agent === 'cursor') {
|
||||||
return <CursorPermissions {...props} />;
|
return <CursorPermissions {...props} />;
|
||||||
}
|
}
|
||||||
if (agent === 'codex') {
|
|
||||||
return <CodexPermissions {...props} />;
|
return <CodexPermissions {...props} />;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user