diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dc10a280..2604f341 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -21,6 +21,7 @@ const CliproxyPage = lazy(() => const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); +const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx index ec12c94c..ccf4ced7 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/app-sidebar.tsx @@ -10,6 +10,7 @@ import { ChevronRight, BarChart3, Gauge, + Github, } from 'lucide-react'; import { Sidebar, @@ -54,6 +55,7 @@ const navGroups = [ { path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' }, ], }, + { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, { path: '/accounts', icon: Users, diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx new file mode 100644 index 00000000..8d3e0dcd --- /dev/null +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -0,0 +1,233 @@ +/** + * Copilot Config Form + * + * Form for configuring GitHub Copilot integration settings. + */ + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useCopilot } from '@/hooks/use-copilot'; +import { Loader2, Save } from 'lucide-react'; +import { toast } from 'sonner'; + +export function CopilotConfigForm() { + const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } = + useCopilot(); + + // Track local overrides for form fields + const [localOverrides, setLocalOverrides] = useState<{ + enabled?: boolean; + autoStart?: boolean; + port?: number; + accountType?: 'individual' | 'business' | 'enterprise'; + model?: string; + rateLimit?: string; + waitOnLimit?: boolean; + }>({}); + + // 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 rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? ''; + const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true; + + const updateField = ( + key: K, + value: (typeof localOverrides)[K] + ) => { + setLocalOverrides((prev) => ({ ...prev, [key]: value })); + }; + + 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 + setLocalOverrides({}); + toast.success('Copilot configuration has been updated.'); + } catch { + toast.error('Failed to save settings.'); + } + }; + + if (configLoading) { + return ( + + + Configuration + + + + + + ); + } + + return ( + + + Configuration + Configure GitHub Copilot integration settings + + + {/* Enable Toggle */} +
+
+ +

+ Allow using GitHub Copilot subscription with Claude Code +

+
+ updateField('enabled', v)} + /> +
+ + {/* 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

+
+ + {/* Model */} +
+ + +

+ 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 +

+
+ updateField('waitOnLimit', v)} + /> +
+ + {/* Auto Start */} +
+
+ +

+ Automatically start copilot-api when using profile +

+
+ updateField('autoStart', v)} + /> +
+ + {/* Save Button */} + +
+
+ ); +} diff --git a/ui/src/components/copilot/copilot-status-card.tsx b/ui/src/components/copilot/copilot-status-card.tsx new file mode 100644 index 00000000..f2a7866a --- /dev/null +++ b/ui/src/components/copilot/copilot-status-card.tsx @@ -0,0 +1,173 @@ +/** + * Copilot Status Card + * + * Displays GitHub Copilot integration status overview. + */ + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { useCopilot } from '@/hooks/use-copilot'; +import { CheckCircle2, XCircle, AlertTriangle, Loader2 } from 'lucide-react'; + +export function CopilotStatusCard() { + const { + status, + statusLoading, + startAuth, + isAuthenticating, + startDaemon, + isStartingDaemon, + stopDaemon, + isStoppingDaemon, + } = useCopilot(); + + if (statusLoading) { + return ( + + + GitHub Copilot Status + + + + + + ); + } + + if (!status) { + return ( + + + GitHub Copilot Status + + +

Failed to load status

+
+
+ ); + } + + return ( + + + + GitHub Copilot Status + {status.enabled ? ( + Enabled + ) : ( + Disabled + )} + + Use your GitHub Copilot subscription with Claude Code + + + {/* Warning Banner */} +
+ +

+ This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection. +

+
+ + {/* Status Grid */} +
+ {/* Installed */} +
+ {status.installed ? ( + + ) : ( + + )} + + copilot-api {status.installed ? 'Installed' : 'Not Installed'} + +
+ + {/* Authenticated */} +
+ {status.authenticated ? ( + + ) : ( + + )} + + {status.authenticated ? 'Authenticated' : 'Not Authenticated'} + +
+ + {/* Daemon */} +
+ {status.daemon_running ? ( + + ) : ( + + )} + Daemon {status.daemon_running ? 'Running' : 'Stopped'} +
+
+ + {/* Quick Info */} +
+ Port: {status.port} + Model: {status.model} + Auto-start: {status.auto_start ? 'Yes' : 'No'} +
+ + {/* Actions */} +
+ {!status.authenticated && ( + + )} + + {status.daemon_running ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts new file mode 100644 index 00000000..b250be50 --- /dev/null +++ b/ui/src/hooks/use-copilot.ts @@ -0,0 +1,174 @@ +/** + * Copilot API Hook + * + * React hook for managing GitHub Copilot integration state. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +const API_BASE = '/api'; + +// Types +export interface CopilotStatus { + enabled: boolean; + installed: boolean; + authenticated: boolean; + daemon_running: boolean; + port: number; + model: string; + account_type: 'individual' | 'business' | 'enterprise'; + auto_start: boolean; + rate_limit: number | null; + wait_on_limit: boolean; +} + +export interface CopilotConfig { + enabled: boolean; + auto_start: boolean; + port: number; + account_type: 'individual' | 'business' | 'enterprise'; + rate_limit: number | null; + wait_on_limit: boolean; + model: string; +} + +export interface CopilotModel { + id: string; + name: string; + provider: 'openai' | 'anthropic'; + isDefault?: boolean; + isCurrent?: boolean; +} + +// API functions +async function fetchCopilotStatus(): Promise { + const res = await fetch(`${API_BASE}/copilot/status`); + if (!res.ok) throw new Error('Failed to fetch copilot status'); + return res.json(); +} + +async function fetchCopilotConfig(): Promise { + const res = await fetch(`${API_BASE}/copilot/config`); + if (!res.ok) throw new Error('Failed to fetch copilot config'); + return res.json(); +} + +async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> { + const res = await fetch(`${API_BASE}/copilot/models`); + if (!res.ok) throw new Error('Failed to fetch copilot models'); + return res.json(); +} + +async function updateCopilotConfig(config: Partial): Promise<{ success: boolean }> { + const res = await fetch(`${API_BASE}/copilot/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error('Failed to update copilot config'); + return res.json(); +} + +async function startCopilotAuth(): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start auth'); + return res.json(); +} + +async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start daemon'); + return res.json(); +} + +async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to stop daemon'); + return res.json(); +} + +// Hook +export function useCopilot() { + const queryClient = useQueryClient(); + + // Queries + const statusQuery = useQuery({ + queryKey: ['copilot-status'], + queryFn: fetchCopilotStatus, + refetchInterval: 5000, // Refresh every 5 seconds + }); + + const configQuery = useQuery({ + queryKey: ['copilot-config'], + queryFn: fetchCopilotConfig, + }); + + const modelsQuery = useQuery({ + queryKey: ['copilot-models'], + queryFn: fetchCopilotModels, + }); + + // Mutations + const updateConfigMutation = useMutation({ + mutationFn: updateCopilotConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + queryClient.invalidateQueries({ queryKey: ['copilot-config'] }); + }, + }); + + const startAuthMutation = useMutation({ + mutationFn: startCopilotAuth, + onSuccess: () => { + // Delay refetch to allow auth to complete + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, 2000); + }, + }); + + const startDaemonMutation = useMutation({ + mutationFn: startCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + const stopDaemonMutation = useMutation({ + mutationFn: stopCopilotDaemon, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['copilot-status'] }); + }, + }); + + return { + // Status + status: statusQuery.data, + statusLoading: statusQuery.isLoading, + statusError: statusQuery.error, + refetchStatus: statusQuery.refetch, + + // Config + config: configQuery.data, + configLoading: configQuery.isLoading, + + // Models + models: modelsQuery.data?.models ?? [], + currentModel: modelsQuery.data?.current, + modelsLoading: modelsQuery.isLoading, + + // Mutations + updateConfig: updateConfigMutation.mutate, + updateConfigAsync: updateConfigMutation.mutateAsync, + isUpdating: updateConfigMutation.isPending, + + startAuth: startAuthMutation.mutate, + isAuthenticating: startAuthMutation.isPending, + + startDaemon: startDaemonMutation.mutate, + isStartingDaemon: startDaemonMutation.isPending, + + stopDaemon: stopDaemonMutation.mutate, + isStoppingDaemon: stopDaemonMutation.isPending, + }; +} diff --git a/ui/src/pages/copilot.tsx b/ui/src/pages/copilot.tsx new file mode 100644 index 00000000..3e990050 --- /dev/null +++ b/ui/src/pages/copilot.tsx @@ -0,0 +1,24 @@ +/** + * Copilot Page + * + * GitHub Copilot integration settings page. + */ + +import { CopilotStatusCard } from '@/components/copilot/copilot-status-card'; +import { CopilotConfigForm } from '@/components/copilot/copilot-config-form'; + +export function CopilotPage() { + return ( +
+
+

GitHub Copilot

+

+ Use your GitHub Copilot subscription with Claude Code +

+
+ + + +
+ ); +}