Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)

This commit is contained in:
Haileyesus
2026-02-25 19:07:07 +03:00
committed by GitHub
parent 23801e9cc1
commit 5e3a7b69d7
149 changed files with 11627 additions and 8453 deletions

View File

@@ -0,0 +1,249 @@
import { Settings as SettingsIcon, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import LoginModal from '../../LoginModal';
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 CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
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,
isSaving,
saveStatus,
deleteError,
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
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' && <GitSettingsTab />}
{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}
deleteError={deleteError}
/>
)}
{activeTab === 'tasks' && (
<div className="space-y-6 md:space-y-8">
<TasksSettingsTab />
</div>
)}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettingsTab />
</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;

View File

@@ -0,0 +1,54 @@
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" role="tablist" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
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>
);
}

View 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&#10;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&#10;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&#10;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>
);
}

View 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&#10;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&#10;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>
);
}

View File

@@ -0,0 +1,193 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from '../../../DarkModeToggle';
import LanguageSelector from '../../../LanguageSelector';
import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
type AppearanceSettingsTabProps = {
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'
} 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({
projectSortOrder,
onProjectSortOrderChange,
codeEditorSettings,
onCodeEditorThemeChange,
onCodeEditorWordWrapChange,
onCodeEditorShowMinimapChange,
onCodeEditorLineNumbersChange,
onCodeEditorFontSizeChange,
}: AppearanceSettingsTabProps) {
const { t } = useTranslation('settings');
const codeEditorThemeLabel = t('appearanceSettings.codeEditor.theme.label');
return (
<div className="space-y-6 md:space-y-8">
<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.darkMode.label')}</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.darkMode.description')}
</div>
</div>
<DarkModeToggle ariaLabel={t('appearanceSettings.darkMode.label')} />
</div>
</div>
</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>
<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">{codeEditorThemeLabel}</div>
<div className="text-sm text-muted-foreground">
{t('appearanceSettings.codeEditor.theme.description')}
</div>
</div>
<DarkModeToggle
checked={codeEditorSettings.theme === 'dark'}
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
ariaLabel={codeEditorThemeLabel}
/>
</div>
</div>
<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>
);
}

View File

@@ -0,0 +1,118 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../types/types';
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: {
name: 'Claude',
color: 'blue',
},
cursor: {
name: 'Cursor',
color: 'purple',
},
codex: {
name: 'Codex',
color: 'gray',
},
};
const colorClasses = {
blue: {
border: 'border-l-blue-500 md:border-l-blue-500',
borderBottom: 'border-b-blue-500',
bg: 'bg-blue-50 dark:bg-blue-900/20',
dot: 'bg-blue-500',
},
purple: {
border: 'border-l-purple-500 md:border-l-purple-500',
borderBottom: 'border-b-purple-500',
bg: 'bg-purple-50 dark:bg-purple-900/20',
dot: 'bg-purple-500',
},
gray: {
border: 'border-l-gray-700 dark:border-l-gray-300',
borderBottom: 'border-b-gray-700 dark:border-b-gray-300',
bg: 'bg-gray-100 dark:bg-gray-800/50',
dot: 'bg-gray-700 dark:bg-gray-300',
},
} as const;
export default function AgentListItem({
agentId,
authStatus,
isSelected,
onClick,
isMobile = false,
}: AgentListItemProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
if (isMobile) {
return (
<button
onClick={onClick}
className={`flex-1 text-center py-3 px-2 border-b-2 transition-colors ${
isSelected
? `${colors.borderBottom} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<div className="flex flex-col items-center gap-1">
<SessionProviderLogo provider={agentId} className="w-5 h-5" />
<span className="text-xs font-medium text-foreground">{config.name}</span>
{authStatus.authenticated && (
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
)}
</div>
</button>
);
}
return (
<button
onClick={onClick}
className={`w-full text-left p-3 border-l-4 transition-colors ${
isSelected
? `${colors.border} ${colors.bg}`
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2 mb-1">
<SessionProviderLogo provider={agentId} className="w-4 h-4" />
<span className="font-medium text-foreground">{config.name}</span>
</div>
<div className="text-xs text-muted-foreground pl-6">
{authStatus.loading ? (
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
) : authStatus.authenticated ? (
<div className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
<span className="truncate max-w-[120px]" title={authStatus.email ?? undefined}>
{authStatus.email || t('agents.authStatus.connected')}
</span>
</div>
) : (
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
<span>{t('agents.authStatus.notConnected')}</span>
</div>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,101 @@
import { useMemo, useState } from 'react';
import type { AgentCategory, AgentProvider } from '../../../types/types';
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
import AgentSelectorSection from './sections/AgentSelectorSection';
import type { AgentContext, AgentsSettingsTabProps } from './types';
export default function AgentsSettingsTab({
claudeAuthStatus,
cursorAuthStatus,
codexAuthStatus,
onClaudeLogin,
onCursorLogin,
onCodexLogin,
claudePermissions,
onClaudePermissionsChange,
cursorPermissions,
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
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]">
<AgentSelectorSection
selectedAgent={selectedAgent}
onSelectAgent={setSelectedAgent}
agentContextById={agentContextById}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<AgentCategoryTabsSection
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
<AgentCategoryContentSection
selectedAgent={selectedAgent}
selectedCategory={selectedCategory}
agentContextById={agentContextById}
claudePermissions={claudePermissions}
onClaudePermissionsChange={onClaudePermissionsChange}
cursorPermissions={cursorPermissions}
onCursorPermissionsChange={onCursorPermissionsChange}
codexPermissionMode={codexPermissionMode}
onCodexPermissionModeChange={onCodexPermissionModeChange}
mcpServers={mcpServers}
cursorMcpServers={cursorMcpServers}
codexMcpServers={codexMcpServers}
mcpTestResults={mcpTestResults}
mcpServerTools={mcpServerTools}
mcpToolsLoading={mcpToolsLoading}
deleteError={deleteError}
onOpenMcpForm={onOpenMcpForm}
onDeleteMcpServer={onDeleteMcpServer}
onTestMcpServer={onTestMcpServer}
onDiscoverMcpTools={onDiscoverMcpTools}
onOpenCodexMcpForm={onOpenCodexMcpForm}
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import AccountContent from './content/AccountContent';
import McpServersContent from './content/McpServersContent';
import PermissionsContent from './content/PermissionsContent';
import type { AgentCategoryContentSectionProps } from '../types';
export default function AgentCategoryContentSection({
selectedAgent,
selectedCategory,
agentContextById,
claudePermissions,
onClaudePermissionsChange,
cursorPermissions,
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
mcpTestResults,
mcpServerTools,
mcpToolsLoading,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onTestMcpServer,
onDiscoverMcpTools,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
}: AgentCategoryContentSectionProps) {
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
const noopCursorMcpAction = () => {};
return (
<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}
deleteError={deleteError}
/>
)}
{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)}
deleteError={deleteError}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next';
import type { AgentCategory } from '../../../../types/types';
import type { AgentCategoryTabsSectionProps } from '../types';
const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export default function AgentCategoryTabsSection({
selectedCategory,
onSelectCategory,
}: AgentCategoryTabsSectionProps) {
const { t } = useTranslation('settings');
return (
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div role="tablist" className="flex px-2 md:px-4 overflow-x-auto">
{AGENT_CATEGORIES.map((category) => (
<button
key={category}
role="tab"
aria-selected={selectedCategory === category}
onClick={() => onSelectCategory(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>
);
}

View File

@@ -0,0 +1,44 @@
import type { AgentProvider } from '../../../../types/types';
import AgentListItem from '../AgentListItem';
import type { AgentSelectorSectionProps } from '../types';
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
export default function AgentSelectorSection({
selectedAgent,
onSelectAgent,
agentContextById,
}: AgentSelectorSectionProps) {
return (
<>
<div className="md:hidden border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex">
{AGENT_PROVIDERS.map((agent) => (
<AgentListItem
key={`mobile-${agent}`}
agentId={agent}
authStatus={agentContextById[agent].authStatus}
isSelected={selectedAgent === agent}
onClick={() => onSelectAgent(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">
{AGENT_PROVIDERS.map((agent) => (
<AgentListItem
key={`desktop-${agent}`}
agentId={agent}
authStatus={agentContextById[agent].authStatus}
isSelected={selectedAgent === agent}
onClick={() => onSelectAgent(agent)}
/>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,134 @@
import { LogIn } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '../../../../../../ui/badge';
import { Button } from '../../../../../../ui/button';
import SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';
import type { AgentProvider, AuthStatus } from '../../../../../types/types';
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: {
name: 'Claude',
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
borderClass: 'border-blue-200 dark:border-blue-800',
textClass: 'text-blue-900 dark:text-blue-100',
subtextClass: 'text-blue-700 dark:text-blue-300',
buttonClass: 'bg-blue-600 hover:bg-blue-700',
},
cursor: {
name: 'Cursor',
bgClass: 'bg-purple-50 dark:bg-purple-900/20',
borderClass: 'border-purple-200 dark:border-purple-800',
textClass: 'text-purple-900 dark:text-purple-100',
subtextClass: 'text-purple-700 dark:text-purple-300',
buttonClass: 'bg-purple-600 hover:bg-purple-700',
},
codex: {
name: 'Codex',
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
borderClass: 'border-gray-300 dark:border-gray-600',
textClass: 'text-gray-900 dark:text-gray-100',
subtextClass: 'text-gray-700 dark:text-gray-300',
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
},
};
export default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
return (
<div className="space-y-6">
<div className="flex items-center gap-3 mb-4">
<SessionProviderLogo provider={agent} className="w-6 h-6" />
<div>
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
</div>
</div>
<div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
{t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.loading ? (
t('agents.authStatus.checkingAuth')
) : authStatus.authenticated ? (
t('agents.authStatus.loggedInAs', {
email: authStatus.email || t('agents.authStatus.authenticatedUser'),
})
) : (
t('agents.authStatus.notConnected')
)}
</div>
</div>
<div>
{authStatus.loading ? (
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
{t('agents.authStatus.checking')}
</Badge>
) : authStatus.authenticated ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
{t('agents.authStatus.connected')}
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
{t('agents.authStatus.disconnected')}
</Badge>
)}
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus.authenticated
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
</div>
</div>
<Button
onClick={onLogin}
className={`${config.buttonClass} text-white`}
size="sm"
>
<LogIn className="w-4 h-4 mr-2" />
{authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div>
</div>
{authStatus.error && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
{t('agents.error', { error: authStatus.error })}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,382 @@
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" />;
};
const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
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>;
deleteError?: string | null;
};
function ClaudeMcpServers({
servers,
onAdd,
onEdit,
onDelete,
testResults,
serverTools,
deleteError,
}: 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>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</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;
deleteError?: string | null;
};
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: 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>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</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}=${maskSecret(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} />;
}

View File

@@ -0,0 +1,597 @@
import { useState } from 'react';
import { AlertTriangle, Plus, Shield, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../../../ui/button';
import { Input } from '../../../../../../ui/input';
import type { CodexPermissionMode } from '../../../../../types/types';
const COMMON_CLAUDE_TOOLS = [
'Bash(git log:*)',
'Bash(git diff:*)',
'Bash(git status:*)',
'Write',
'Read',
'Edit',
'Glob',
'Grep',
'MultiEdit',
'Task',
'TodoWrite',
'TodoRead',
'WebFetch',
'WebSearch',
];
const COMMON_CURSOR_COMMANDS = [
'Shell(ls)',
'Shell(mkdir)',
'Shell(cd)',
'Shell(cat)',
'Shell(echo)',
'Shell(git status)',
'Shell(git diff)',
'Shell(git log)',
'Shell(npm install)',
'Shell(npm run)',
'Shell(python)',
'Shell(node)',
];
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({
skipPermissions,
onSkipPermissionsChange,
allowedTools,
onAllowedToolsChange,
disallowedTools,
onDisallowedToolsChange,
}: Omit<ClaudePermissionsProps, 'agent'>) {
const { t } = useTranslation('settings');
const [newAllowedTool, setNewAllowedTool] = useState('');
const [newDisallowedTool, setNewDisallowedTool] = useState('');
const handleAddAllowedTool = (tool: string) => {
const updated = addUnique(allowedTools, tool);
if (updated.length === allowedTools.length) {
return;
}
onAllowedToolsChange(updated);
setNewAllowedTool('');
};
const handleAddDisallowedTool = (tool: string) => {
const updated = addUnique(disallowedTools, tool);
if (updated.length === disallowedTools.length) {
return;
}
onDisallowedToolsChange(updated);
setNewDisallowedTool('');
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.title')}</h3>
</div>
<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">
<input
type="checkbox"
checked={skipPermissions}
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"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
{t('permissions.skipPermissions.label')}
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
{t('permissions.skipPermissions.claudeDescription')}
</div>
</div>
</label>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.allowedTools.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('permissions.allowedTools.description')}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedTool}
onChange={(event) => setNewAllowedTool(event.target.value)}
placeholder={t('permissions.allowedTools.placeholder')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddAllowedTool(newAllowedTool);
}
}}
className="flex-1 h-10"
/>
<Button
onClick={() => handleAddAllowedTool(newAllowedTool)}
disabled={!newAllowedTool.trim()}
size="sm"
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('permissions.allowedTools.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
{COMMON_CLAUDE_TOOLS.map((tool) => (
<Button
key={tool}
variant="outline"
size="sm"
onClick={() => handleAddAllowedTool(tool)}
disabled={allowedTools.includes(tool)}
className="text-xs h-8"
>
{tool}
</Button>
))}
</div>
</div>
<div className="space-y-2">
{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">
<span className="font-mono text-sm text-green-800 dark:text-green-200">{tool}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onAllowedToolsChange(removeValue(allowedTools, tool))}
className="text-green-600 hover:text-green-700"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{allowedTools.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
{t('permissions.allowedTools.empty')}
</div>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.blockedTools.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('permissions.blockedTools.description')}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedTool}
onChange={(event) => setNewDisallowedTool(event.target.value)}
placeholder={t('permissions.blockedTools.placeholder')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddDisallowedTool(newDisallowedTool);
}
}}
className="flex-1 h-10"
/>
<Button
onClick={() => handleAddDisallowedTool(newDisallowedTool)}
disabled={!newDisallowedTool.trim()}
size="sm"
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
<div className="space-y-2">
{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">
<span className="font-mono text-sm text-red-800 dark:text-red-200">{tool}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onDisallowedToolsChange(removeValue(disallowedTools, tool))}
className="text-red-600 hover:text-red-700"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{disallowedTools.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
{t('permissions.blockedTools.empty')}
</div>
)}
</div>
</div>
<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">
{t('permissions.toolExamples.title')}
</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> {t('permissions.toolExamples.bashGitLog')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> {t('permissions.toolExamples.bashGitDiff')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> {t('permissions.toolExamples.write')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
</ul>
</div>
</div>
);
}
type CursorPermissionsProps = {
agent: 'cursor';
skipPermissions: boolean;
onSkipPermissionsChange: (value: boolean) => void;
allowedCommands: string[];
onAllowedCommandsChange: (value: string[]) => void;
disallowedCommands: string[];
onDisallowedCommandsChange: (value: string[]) => void;
};
function CursorPermissions({
skipPermissions,
onSkipPermissionsChange,
allowedCommands,
onAllowedCommandsChange,
disallowedCommands,
onDisallowedCommandsChange,
}: Omit<CursorPermissionsProps, 'agent'>) {
const { t } = useTranslation('settings');
const [newAllowedCommand, setNewAllowedCommand] = useState('');
const [newDisallowedCommand, setNewDisallowedCommand] = useState('');
const handleAddAllowedCommand = (command: string) => {
const updated = addUnique(allowedCommands, command);
if (updated.length === allowedCommands.length) {
return;
}
onAllowedCommandsChange(updated);
setNewAllowedCommand('');
};
const handleAddDisallowedCommand = (command: string) => {
const updated = addUnique(disallowedCommands, command);
if (updated.length === disallowedCommands.length) {
return;
}
onDisallowedCommandsChange(updated);
setNewDisallowedCommand('');
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.title')}</h3>
</div>
<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">
<input
type="checkbox"
checked={skipPermissions}
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"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
{t('permissions.skipPermissions.label')}
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
{t('permissions.skipPermissions.cursorDescription')}
</div>
</div>
</label>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.allowedCommands.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('permissions.allowedCommands.description')}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedCommand}
onChange={(event) => setNewAllowedCommand(event.target.value)}
placeholder={t('permissions.allowedCommands.placeholder')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddAllowedCommand(newAllowedCommand);
}
}}
className="flex-1 h-10"
/>
<Button
onClick={() => handleAddAllowedCommand(newAllowedCommand)}
disabled={!newAllowedCommand.trim()}
size="sm"
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('permissions.allowedCommands.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
{COMMON_CURSOR_COMMANDS.map((command) => (
<Button
key={command}
variant="outline"
size="sm"
onClick={() => handleAddAllowedCommand(command)}
disabled={allowedCommands.includes(command)}
className="text-xs h-8"
>
{command}
</Button>
))}
</div>
</div>
<div className="space-y-2">
{allowedCommands.map((command) => (
<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">{command}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onAllowedCommandsChange(removeValue(allowedCommands, command))}
className="text-green-600 hover:text-green-700"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{allowedCommands.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
{t('permissions.allowedCommands.empty')}
</div>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.blockedCommands.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('permissions.blockedCommands.description')}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedCommand}
onChange={(event) => setNewDisallowedCommand(event.target.value)}
placeholder={t('permissions.blockedCommands.placeholder')}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddDisallowedCommand(newDisallowedCommand);
}
}}
className="flex-1 h-10"
/>
<Button
onClick={() => handleAddDisallowedCommand(newDisallowedCommand)}
disabled={!newDisallowedCommand.trim()}
size="sm"
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
<div className="space-y-2">
{disallowedCommands.map((command) => (
<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">{command}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onDisallowedCommandsChange(removeValue(disallowedCommands, command))}
className="text-red-600 hover:text-red-700"
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{disallowedCommands.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
{t('permissions.blockedCommands.empty')}
</div>
)}
</div>
</div>
<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">
{t('permissions.shellExamples.title')}
</h4>
<ul className="text-sm text-purple-800 dark:text-purple-200 space-y-1">
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> {t('permissions.shellExamples.ls')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> {t('permissions.shellExamples.gitStatus')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> {t('permissions.shellExamples.npmInstall')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> {t('permissions.shellExamples.rmRf')}</li>
</ul>
</div>
</div>
);
}
type CodexPermissionsProps = {
agent: 'codex';
permissionMode: CodexPermissionMode;
onPermissionModeChange: (value: CodexPermissionMode) => void;
};
function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<CodexPermissionsProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">{t('permissions.codex.permissionMode')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('permissions.codex.description')}</p>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'default'
? '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'
}`}
onClick={() => onPermissionModeChange('default')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="codexPermissionMode"
checked={permissionMode === 'default'}
onChange={() => onPermissionModeChange('default')}
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-foreground">{t('permissions.codex.modes.default.title')}</div>
<div className="text-sm text-muted-foreground">
{t('permissions.codex.modes.default.description')}
</div>
</div>
</label>
</div>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'acceptEdits'
? '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'
}`}
onClick={() => onPermissionModeChange('acceptEdits')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="codexPermissionMode"
checked={permissionMode === 'acceptEdits'}
onChange={() => onPermissionModeChange('acceptEdits')}
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-green-900 dark:text-green-100">{t('permissions.codex.modes.acceptEdits.title')}</div>
<div className="text-sm text-green-700 dark:text-green-300">
{t('permissions.codex.modes.acceptEdits.description')}
</div>
</div>
</label>
</div>
<div
className={`border rounded-lg p-4 cursor-pointer transition-all ${
permissionMode === 'bypassPermissions'
? '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'
}`}
onClick={() => onPermissionModeChange('bypassPermissions')}
>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
name="codexPermissionMode"
checked={permissionMode === 'bypassPermissions'}
onChange={() => onPermissionModeChange('bypassPermissions')}
className="mt-1 w-4 h-4 text-orange-600"
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
{t('permissions.codex.modes.bypassPermissions.title')}
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
{t('permissions.codex.modes.bypassPermissions.description')}
</div>
</div>
</label>
</div>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{t('permissions.codex.technicalDetails')}
</summary>
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs text-muted-foreground space-y-2">
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
<p className="text-xs opacity-75">{t('permissions.codex.technicalInfo.overrideNote')}</p>
</div>
</details>
</div>
</div>
);
}
type PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps;
export default function PermissionsContent(props: PermissionsContentProps) {
if (props.agent === 'claude') {
return <ClaudePermissions {...props} />;
}
if (props.agent === 'cursor') {
return <CursorPermissions {...props} />;
}
return <CodexPermissions {...props} />;
}

View File

@@ -0,0 +1,82 @@
import type {
AgentProvider,
AuthStatus,
AgentCategory,
ClaudePermissionsState,
CodexPermissionMode,
CursorPermissionsState,
McpServer,
McpToolsResult,
McpTestResult,
} from '../../../types/types';
export type AgentContext = {
authStatus: AuthStatus;
onLogin: () => void;
};
export type AgentContextByProvider = Record<AgentProvider, AgentContext>;
export 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>;
deleteError: string | null;
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;
};
export type AgentCategoryTabsSectionProps = {
selectedCategory: AgentCategory;
onSelectCategory: (category: AgentCategory) => void;
};
export type AgentSelectorSectionProps = {
selectedAgent: AgentProvider;
onSelectAgent: (agent: AgentProvider) => void;
agentContextById: AgentContextByProvider;
};
export type AgentCategoryContentSectionProps = {
selectedAgent: AgentProvider;
selectedCategory: AgentCategory;
agentContextById: AgentContextByProvider;
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>;
deleteError: string | null;
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;
};

View File

@@ -0,0 +1,100 @@
import { useTranslation } from 'react-i18next';
import { useVersionCheck } from '../../../../../hooks/useVersionCheck';
import { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';
import ApiKeysSection from './sections/ApiKeysSection';
import GithubCredentialsSection from './sections/GithubCredentialsSection';
import NewApiKeyAlert from './sections/NewApiKeyAlert';
import VersionInfoSection from './sections/VersionInfoSection';
export default function CredentialsSettingsTab() {
const { t } = useTranslation('settings');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const {
apiKeys,
githubCredentials,
loading,
showNewKeyForm,
setShowNewKeyForm,
newKeyName,
setNewKeyName,
showNewGithubForm,
setShowNewGithubForm,
newGithubName,
setNewGithubName,
newGithubToken,
setNewGithubToken,
newGithubDescription,
setNewGithubDescription,
showToken,
copiedKey,
newlyCreatedKey,
createApiKey,
deleteApiKey,
toggleApiKey,
createGithubCredential,
deleteGithubCredential,
toggleGithubCredential,
copyToClipboard,
dismissNewlyCreatedKey,
cancelNewApiKeyForm,
cancelNewGithubForm,
toggleNewGithubTokenVisibility,
} = useCredentialsSettings({
confirmDeleteApiKeyText: t('apiKeys.confirmDelete'),
confirmDeleteGithubCredentialText: t('apiKeys.github.confirmDelete'),
});
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{newlyCreatedKey && (
<NewApiKeyAlert
apiKey={newlyCreatedKey}
copiedKey={copiedKey}
onCopy={copyToClipboard}
onDismiss={dismissNewlyCreatedKey}
/>
)}
<ApiKeysSection
apiKeys={apiKeys}
showNewKeyForm={showNewKeyForm}
newKeyName={newKeyName}
onShowNewKeyFormChange={setShowNewKeyForm}
onNewKeyNameChange={setNewKeyName}
onCreateApiKey={createApiKey}
onCancelCreateApiKey={cancelNewApiKeyForm}
onToggleApiKey={toggleApiKey}
onDeleteApiKey={deleteApiKey}
/>
<GithubCredentialsSection
githubCredentials={githubCredentials}
showNewGithubForm={showNewGithubForm}
showNewTokenPlainText={Boolean(showToken.new)}
newGithubName={newGithubName}
newGithubToken={newGithubToken}
newGithubDescription={newGithubDescription}
onShowNewGithubFormChange={setShowNewGithubForm}
onNewGithubNameChange={setNewGithubName}
onNewGithubTokenChange={setNewGithubToken}
onNewGithubDescriptionChange={setNewGithubDescription}
onToggleNewTokenVisibility={toggleNewGithubTokenVisibility}
onCreateGithubCredential={createGithubCredential}
onCancelCreateGithubCredential={cancelNewGithubForm}
onToggleGithubCredential={toggleGithubCredential}
onDeleteGithubCredential={deleteGithubCredential}
/>
<VersionInfoSection
currentVersion={currentVersion}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
releaseInfo={releaseInfo}
/>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { ExternalLink, Key, Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../../ui/button';
import { Input } from '../../../../../ui/input';
import type { ApiKeyItem } from '../types';
type ApiKeysSectionProps = {
apiKeys: ApiKeyItem[];
showNewKeyForm: boolean;
newKeyName: string;
onShowNewKeyFormChange: (value: boolean) => void;
onNewKeyNameChange: (value: string) => void;
onCreateApiKey: () => void;
onCancelCreateApiKey: () => void;
onToggleApiKey: (keyId: string, isActive: boolean) => void;
onDeleteApiKey: (keyId: string) => void;
};
export default function ApiKeysSection({
apiKeys,
showNewKeyForm,
newKeyName,
onShowNewKeyFormChange,
onNewKeyNameChange,
onCreateApiKey,
onCancelCreateApiKey,
onToggleApiKey,
onDeleteApiKey,
}: ApiKeysSectionProps) {
const { t } = useTranslation('settings');
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button size="sm" onClick={() => onShowNewKeyFormChange(!showNewKeyForm)}>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">{t('apiKeys.description')}</p>
<a
href="/api-docs.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
{t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(event) => onNewKeyNameChange(event.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={onCreateApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={onCancelCreateApiKey}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div key={key.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used
? ` - ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`
: ''}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => onToggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button size="sm" variant="ghost" onClick={() => onDeleteApiKey(key.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { Eye, EyeOff, Github, Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../../ui/button';
import { Input } from '../../../../../ui/input';
import type { GithubCredentialItem } from '../types';
type GithubCredentialsSectionProps = {
githubCredentials: GithubCredentialItem[];
showNewGithubForm: boolean;
showNewTokenPlainText: boolean;
newGithubName: string;
newGithubToken: string;
newGithubDescription: string;
onShowNewGithubFormChange: (value: boolean) => void;
onNewGithubNameChange: (value: string) => void;
onNewGithubTokenChange: (value: string) => void;
onNewGithubDescriptionChange: (value: string) => void;
onToggleNewTokenVisibility: () => void;
onCreateGithubCredential: () => void;
onCancelCreateGithubCredential: () => void;
onToggleGithubCredential: (credentialId: string, isActive: boolean) => void;
onDeleteGithubCredential: (credentialId: string) => void;
};
export default function GithubCredentialsSection({
githubCredentials,
showNewGithubForm,
showNewTokenPlainText,
newGithubName,
newGithubToken,
newGithubDescription,
onShowNewGithubFormChange,
onNewGithubNameChange,
onNewGithubTokenChange,
onNewGithubDescriptionChange,
onToggleNewTokenVisibility,
onCreateGithubCredential,
onCancelCreateGithubCredential,
onToggleGithubCredential,
onDeleteGithubCredential,
}: GithubCredentialsSectionProps) {
const { t } = useTranslation('settings');
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button size="sm" onClick={() => onShowNewGithubFormChange(!showNewGithubForm)}>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">{t('apiKeys.github.descriptionAlt')}</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName}
onChange={(event) => onNewGithubNameChange(event.target.value)}
/>
<div className="relative">
<Input
type={showNewTokenPlainText ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(event) => onNewGithubTokenChange(event.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={onToggleNewTokenVisibility}
aria-label={showNewTokenPlainText ? 'Hide token' : 'Show token'}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showNewTokenPlainText ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Input
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription}
onChange={(event) => onNewGithubDescriptionChange(event.target.value)}
/>
<div className="flex gap-2">
<Button onClick={onCreateGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={onCancelCreateGithubCredential}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
{t('apiKeys.github.form.howToCreate')}
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubCredentials.map((credential) => (
<div key={credential.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{credential.credential_name}</div>
{credential.description && (
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => onToggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button size="sm" variant="ghost" onClick={() => onDeleteGithubCredential(credential.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Check, Copy } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../../../ui/button';
import type { CreatedApiKey } from '../types';
type NewApiKeyAlertProps = {
apiKey: CreatedApiKey;
copiedKey: string | null;
onCopy: (text: string, id: string) => void;
onDismiss: () => void;
};
export default function NewApiKeyAlert({
apiKey,
copiedKey,
onCopy,
onDismiss,
}: NewApiKeyAlertProps) {
const { t } = useTranslation('settings');
return (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">{t('apiKeys.newKey.alertMessage')}</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{apiKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => onCopy(apiKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button size="sm" variant="ghost" className="mt-3" onClick={onDismiss}>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ReleaseInfo } from '../../../../../../types/sharedTypes';
type VersionInfoSectionProps = {
currentVersion: string;
updateAvailable: boolean;
latestVersion: string | null;
releaseInfo: ReleaseInfo | null;
};
export default function VersionInfoSection({
currentVersion,
updateAvailable,
latestVersion,
releaseInfo,
}: VersionInfoSectionProps) {
const { t } = useTranslation('settings');
const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';
return (
<div className="pt-6 border-t border-border/50">
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground transition-colors"
>
v{currentVersion}
</a>
{updateAvailable && latestVersion && (
<a
href={releasesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
>
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
export type ApiKeyItem = {
id: string;
key_name: string;
api_key: string;
created_at: string;
last_used?: string | null;
is_active: boolean;
};
export type CreatedApiKey = {
id: string;
keyName: string;
apiKey: string;
createdAt?: string;
};
export type GithubCredentialItem = {
id: string;
credential_name: string;
description?: string | null;
created_at: string;
is_active: boolean;
};
export type ApiKeysResponse = {
apiKeys?: ApiKeyItem[];
success?: boolean;
error?: string;
apiKey?: CreatedApiKey;
};
export type GithubCredentialsResponse = {
credentials?: GithubCredentialItem[];
success?: boolean;
error?: string;
};

View File

@@ -0,0 +1,82 @@
import { Check, GitBranch } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useGitSettings } from '../../../hooks/useGitSettings';
import { Button } from '../../../../ui/button';
import { Input } from '../../../../ui/input';
export default function GitSettingsTab() {
const { t } = useTranslation('settings');
const {
gitName,
setGitName,
gitEmail,
setGitEmail,
isLoading,
isSaving,
saveStatus,
saveGitConfig,
} = useGitSettings();
return (
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">{t('git.description')}</p>
<div className="p-4 border rounded-lg bg-card space-y-3">
<div>
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(event) => setGitName(event.target.value)}
placeholder="John Doe"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.name.help')}</p>
</div>
<div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(event) => setGitEmail(event.target.value)}
placeholder="john@example.com"
disabled={isLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">{t('git.email.help')}</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={isSaving || !gitName.trim() || !gitEmail.trim()}
>
{isSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" />
{t('git.status.success')}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';
type TasksSettingsContextValue = {
tasksEnabled: boolean;
setTasksEnabled: (enabled: boolean) => void;
isTaskMasterInstalled: boolean | null;
isCheckingInstallation: boolean;
};
export default function TasksSettingsTab() {
const { t } = useTranslation('settings');
const {
tasksEnabled,
setTasksEnabled,
isTaskMasterInstalled,
isCheckingInstallation,
} = useTasksSettings() as TasksSettingsContextValue;
return (
<div className="space-y-8">
{isCheckingInstallation ? (
<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 gap-3">
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full" />
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</div>
) : (
<>
{!isTaskMasterInstalled && (
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
{t('tasks.notInstalled.title')}
</div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
<p>{t('tasks.notInstalled.description')}</p>
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
<code>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
{t('tasks.notInstalled.viewOnGitHub')}
<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="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
{isTaskMasterInstalled && (
<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('tasks.settings.enableLabel')}</div>
<div className="text-sm text-muted-foreground mt-1">{t('tasks.settings.enableDescription')}</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={tasksEnabled}
onChange={(event) => setTasksEnabled(event.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600" />
</label>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}