diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx
index 8d3e0dcd..cb9a37e3 100644
--- a/ui/src/components/copilot/copilot-config-form.tsx
+++ b/ui/src/components/copilot/copilot-config-form.tsx
@@ -2,28 +2,129 @@
* Copilot Config Form
*
* Form for configuring GitHub Copilot integration settings.
+ * Split-view layout matching CLIProxy provider editor:
+ * - Left: Friendly UI with model mapping selectors
+ * - Right: Raw JSON editor for copilot.settings.json
*/
-import { useState } from 'react';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
+import { Separator } from '@/components/ui/separator';
+import { Skeleton } from '@/components/ui/skeleton';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
+import { CopyButton } from '@/components/ui/copy-button';
+import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
+ SelectGroup,
+ SelectLabel,
} from '@/components/ui/select';
import { useCopilot } from '@/hooks/use-copilot';
-import { Loader2, Save } from 'lucide-react';
+import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react';
import { toast } from 'sonner';
+import { ConfirmDialog } from '@/components/confirm-dialog';
+
+// Lazy load CodeEditor
+const CodeEditor = lazy(() =>
+ import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
+);
+
+// Model presets for quick configuration
+const MODEL_PRESETS = [
+ {
+ name: 'Claude Opus 4.5',
+ default: 'claude-opus-4-5-20250514',
+ opus: 'claude-opus-4-5-20250514',
+ sonnet: 'claude-sonnet-4-20250514',
+ haiku: 'claude-haiku-3-5-20250514',
+ },
+ {
+ name: 'Claude Sonnet 4',
+ default: 'claude-sonnet-4-20250514',
+ opus: 'claude-sonnet-4-20250514',
+ sonnet: 'claude-sonnet-4-20250514',
+ haiku: 'claude-haiku-3-5-20250514',
+ },
+ {
+ name: 'GPT-4o',
+ default: 'gpt-4o',
+ opus: 'gpt-4o',
+ sonnet: 'gpt-4o',
+ haiku: 'gpt-4o-mini',
+ },
+];
+
+interface FlexibleModelSelectorProps {
+ label: string;
+ description?: string;
+ value: string | undefined;
+ onChange: (model: string) => void;
+ models: { id: string }[];
+ disabled?: boolean;
+}
+
+function FlexibleModelSelector({
+ label,
+ description,
+ value,
+ onChange,
+ models,
+ disabled,
+}: FlexibleModelSelectorProps) {
+ return (
+
+
+
+ {description &&
{description}
}
+
+
+
+ );
+}
export function CopilotConfigForm() {
- const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } =
- useCopilot();
+ const {
+ config,
+ configLoading,
+ models,
+ modelsLoading,
+ rawSettings,
+ rawSettingsLoading,
+ updateConfigAsync,
+ isUpdating,
+ saveRawSettingsAsync,
+ isSavingRawSettings,
+ refetchRawSettings,
+ } = useCopilot();
// Track local overrides for form fields
const [localOverrides, setLocalOverrides] = useState<{
@@ -34,16 +135,26 @@ export function CopilotConfigForm() {
model?: string;
rateLimit?: string;
waitOnLimit?: boolean;
+ opusModel?: string;
+ sonnetModel?: string;
+ haikuModel?: string;
}>({});
+ // Raw JSON editor state
+ const [rawJsonEdits, setRawJsonEdits] = useState(null);
+ const [conflictDialog, setConflictDialog] = useState(false);
+
// Use local overrides if set, otherwise use config values
const enabled = localOverrides.enabled ?? config?.enabled ?? false;
const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false;
const port = localOverrides.port ?? config?.port ?? 4141;
const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual';
- const model = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514';
+ const currentModel = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514';
const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? '';
const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true;
+ const opusModel = localOverrides.opusModel ?? config?.opus_model ?? '';
+ const sonnetModel = localOverrides.sonnetModel ?? config?.sonnet_model ?? '';
+ const haikuModel = localOverrides.haikuModel ?? config?.haiku_model ?? '';
const updateField = (
key: K,
@@ -52,182 +163,521 @@ export function CopilotConfigForm() {
setLocalOverrides((prev) => ({ ...prev, [key]: value }));
};
+ // Batch update for presets
+ const applyPreset = (preset: (typeof MODEL_PRESETS)[0]) => {
+ setLocalOverrides((prev) => ({
+ ...prev,
+ model: preset.default,
+ opusModel: preset.opus,
+ sonnetModel: preset.sonnet,
+ haikuModel: preset.haiku,
+ }));
+ toast.success(`Applied "${preset.name}" preset`);
+ };
+
+ // Raw JSON content
+ const rawJsonContent = useMemo(() => {
+ if (rawJsonEdits !== null) return rawJsonEdits;
+ if (rawSettings?.settings) return JSON.stringify(rawSettings.settings, null, 2);
+ return '{\n "env": {}\n}';
+ }, [rawJsonEdits, rawSettings]);
+
+ const handleRawJsonChange = useCallback((value: string) => {
+ setRawJsonEdits(value);
+ }, []);
+
+ // Check if JSON is valid
+ const isRawJsonValid = useMemo(() => {
+ try {
+ JSON.parse(rawJsonContent);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [rawJsonContent]);
+
+ // Check for unsaved changes
+ const hasChanges = useMemo(() => {
+ const hasLocalChanges = Object.keys(localOverrides).length > 0;
+ const hasJsonChanges =
+ rawJsonEdits !== null && rawJsonEdits !== JSON.stringify(rawSettings?.settings, null, 2);
+ return hasLocalChanges || hasJsonChanges;
+ }, [localOverrides, rawJsonEdits, rawSettings]);
+
const handleSave = async () => {
try {
- await updateConfigAsync({
- enabled,
- auto_start: autoStart,
- port,
- account_type: accountType,
- model,
- rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
- wait_on_limit: waitOnLimit,
- });
- // Clear local overrides after successful save
+ // Save config changes
+ if (Object.keys(localOverrides).length > 0) {
+ await updateConfigAsync({
+ enabled,
+ auto_start: autoStart,
+ port,
+ account_type: accountType,
+ model: currentModel,
+ rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
+ wait_on_limit: waitOnLimit,
+ opus_model: opusModel || undefined,
+ sonnet_model: sonnetModel || undefined,
+ haiku_model: haikuModel || undefined,
+ });
+ }
+
+ // Save raw JSON changes
+ if (rawJsonEdits !== null && isRawJsonValid) {
+ const settingsToSave = JSON.parse(rawJsonContent);
+ await saveRawSettingsAsync({
+ settings: settingsToSave,
+ expectedMtime: rawSettings?.mtime,
+ });
+ }
+
+ // Clear local state
setLocalOverrides({});
- toast.success('Copilot configuration has been updated.');
- } catch {
- toast.error('Failed to save settings.');
+ setRawJsonEdits(null);
+ toast.success('Copilot configuration saved');
+ } catch (error) {
+ if ((error as Error).message === 'CONFLICT') {
+ setConflictDialog(true);
+ } else {
+ toast.error('Failed to save settings');
+ }
}
};
- if (configLoading) {
+ const handleConflictResolve = async (overwrite: boolean) => {
+ setConflictDialog(false);
+ if (overwrite) {
+ await refetchRawSettings();
+ handleSave();
+ } else {
+ setRawJsonEdits(null);
+ }
+ };
+
+ if (configLoading || rawSettingsLoading) {
return (
-
-
- Configuration
-
-
-
-
-
+
+
+
+
+
+
);
}
- return (
-
-
- Configuration
- Configure GitHub Copilot integration settings
-
-
- {/* Enable Toggle */}
-
-
-
-
- Allow using GitHub Copilot subscription with Claude Code
-
-
-
updateField('enabled', v)}
- />
+ // Left column: Friendly UI
+ const renderFriendlyUI = () => (
+
+
+
+
+
+ Model Config
+
+
+ Settings
+
+
+ Info
+
+
- {/* Port */}
-
-
-
updateField('port', parseInt(e.target.value, 10))}
- min={1024}
- max={65535}
- />
-
- Local port for copilot-api proxy (default: 4141)
-
-
-
- {/* Account Type */}
-
-
-
+ );
+
+ // Right column: Raw JSON Editor
+ const renderRawEditor = () => (
+
+
+ Loading editor...
+
+ }
+ >
+
+ {!isRawJsonValid && rawJsonEdits !== null && (
+
+
+ Invalid JSON syntax
+
+ )}
+
+
+
+ );
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Copilot Configuration
+ {rawSettings && (
+
+ copilot.settings.json
+
)}
-
-
-
- Model to use via Copilot (default: Claude Opus 4.5)
-
-
-
- {/* Rate Limit */}
-
-
-
updateField('rateLimit', e.target.value)}
- placeholder="No limit"
- min={0}
- />
-
- Minimum seconds between requests (leave empty for no limit)
-
-
-
- {/* Wait on Limit */}
-
-
-
-
- Wait instead of error when rate limit is hit
-
+
+ {rawSettings && (
+
+ Last modified:{' '}
+ {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
+
+ )}
-
updateField('waitOnLimit', v)}
- />
+
+
+
+
+
- {/* Auto Start */}
-
-
-
-
- Automatically start copilot-api when using profile
-
+ {/* Split Layout (40% Left / 60% Right) */}
+
+ {/* Left Column: Friendly UI */}
+
{renderFriendlyUI()}
+
+ {/* Right Column: Raw Editor */}
+
+
+
+
+ Raw Configuration (JSON)
+
-
updateField('autoStart', v)}
- />
+ {renderRawEditor()}
+
- {/* Save Button */}
-
-
-
+
handleConflictResolve(true)}
+ onCancel={() => handleConflictResolve(false)}
+ />
+
+ );
+}
+
+/** Usage command with copy button */
+function UsageCommand({ label, command }: { label: string; command: string }) {
+ return (
+
+
+
+
+ {command}
+
+
+
+
);
}
diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx
index 3e990050..57c81ad2 100644
--- a/ui/src/pages/copilot.tsx
+++ b/ui/src/pages/copilot.tsx
@@ -1,24 +1,273 @@
/**
- * Copilot Page
- *
- * GitHub Copilot integration settings page.
+ * Copilot Page - Master-Detail Layout
+ * Left sidebar: Status overview + Quick actions
+ * Right panel: Split-view configuration form (matches CLIProxy design)
*/
-import { CopilotStatusCard } from '@/components/copilot/copilot-status-card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Github,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ RefreshCw,
+ Power,
+ PowerOff,
+ Key,
+ Server,
+ Cpu,
+} from 'lucide-react';
+import { useCopilot } from '@/hooks/use-copilot';
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
+import { cn } from '@/lib/utils';
-export function CopilotPage() {
+// Status section component
+function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
-
-
-
GitHub Copilot
-
- Use your GitHub Copilot subscription with Claude Code
-
+
+
+ {title}
+
+
{children}
+
+ );
+}
+
+// Status item component
+function StatusItem({
+ icon: Icon,
+ label,
+ status,
+ statusText,
+ variant = 'default',
+}: {
+ icon: React.ElementType;
+ label: string;
+ status: boolean;
+ statusText?: string;
+ variant?: 'default' | 'warning';
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {status ? (
+ <>
+
+
+ {statusText || 'Yes'}
+
+ >
+ ) : (
+ <>
+
+ {statusText || 'No'}
+ >
+ )}
+
+
+ );
+}
+
+// Empty state when loading
+function LoadingSidebar() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function CopilotPage() {
+ const {
+ status,
+ statusLoading,
+ refetchStatus,
+ startAuth,
+ isAuthenticating,
+ startDaemon,
+ isStartingDaemon,
+ stopDaemon,
+ isStoppingDaemon,
+ } = useCopilot();
+
+ return (
+
+ {/* Left Sidebar - Status Overview */}
+
+ {/* Header */}
+
+
+
+
+
Copilot
+
+
+
+
GitHub Copilot proxy
+
+
+ {/* Status Overview */}
+
+ {statusLoading ? (
+
+ ) : (
+
+ {/* Warning Banner */}
+
+
+
+ Reverse-engineered API
+
+
+
+ {/* Integration Status */}
+
+
+
+
+
+ {/* Authentication */}
+
+
+ {!status?.authenticated && status?.installed && (
+
+ )}
+
+
+ {/* Daemon */}
+
+
+
+ Port: {status?.port ?? 4141}
+
+ {status?.authenticated && (
+
+ {status?.daemon_running ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {/* Quick Status */}
+
+
+
+ Model
+
+ {status?.model ?? 'N/A'}
+
+
+
+ Account
+
+ {status?.account_type ?? 'individual'}
+
+
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ Proxy
+ {status?.daemon_running ? (
+
+
+ Active
+
+ ) : (
+
+
+ Inactive
+
+ )}
+
+
+
+
+ {/* Right Panel - Split-view Configuration Form */}
+
+
-
-
-
);
}