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 */} -
- - -

Your GitHub Copilot subscription type

-
+ +
+ {/* Quick Presets */} +
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+
+ {MODEL_PRESETS.map((preset) => ( + + ))} +
+
- {/* Model */} -
- - updateField('port', parseInt(e.target.value, 10))} + min={1024} + max={65535} + className="max-w-[150px] h-8" + /> +
+ + {/* Account Type */} +
+ + +
+
+ + + + {/* Rate Limiting */} +
+

Rate Limiting

+ +
+ + updateField('rateLimit', e.target.value)} + placeholder="No limit" + min={0} + className="max-w-[150px] h-8" + /> +
+ +
+
+ +

+ Wait instead of error when limit hit +

+
+ updateField('waitOnLimit', v)} + /> +
+
+ + + + {/* Daemon Settings */} +
+

Daemon Settings

+ +
+
+ +

+ Start copilot-api when using profile +

+
+ updateField('autoStart', v)} + /> +
+
+
+ + + + {/* Info Tab */} + + +
+
+

+ + Configuration Info +

+
+
+ Provider + GitHub Copilot +
+ {rawSettings && ( + <> +
+ File Path +
+ + {rawSettings.path} + + +
+
+
+ Status + + {rawSettings.exists ? 'File exists' : 'Using defaults'} + +
+ + )} +
+
+ +
+

Quick Usage

+
+ + + + +
+
+
+
+
+
+ + + ); + + // 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 */} +
+
- - -
); }