diff --git a/ui/src/components/copilot/config-form/header-section.tsx b/ui/src/components/copilot/config-form/header-section.tsx new file mode 100644 index 00000000..f276cff5 --- /dev/null +++ b/ui/src/components/copilot/config-form/header-section.tsx @@ -0,0 +1,82 @@ +/** + * Header Section + * Top header with title, badge, last modified, and action buttons + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, Save, RefreshCw } from 'lucide-react'; + +interface RawSettings { + path: string; + exists: boolean; + mtime: number; + settings?: Record; +} + +interface HeaderSectionProps { + rawSettings: RawSettings | undefined; + rawSettingsLoading: boolean; + isUpdating: boolean; + isSavingRawSettings: boolean; + hasChanges: boolean; + isRawJsonValid: boolean; + onRefresh: () => void; + onSave: () => void; +} + +export function HeaderSection({ + rawSettings, + rawSettingsLoading, + isUpdating, + isSavingRawSettings, + hasChanges, + isRawJsonValid, + onRefresh, + onSave, +}: HeaderSectionProps) { + return ( +
+
+
+
+

Copilot Configuration

+ {rawSettings && ( + + copilot.settings.json + + )} +
+ {rawSettings && ( +

+ Last modified:{' '} + {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'} +

+ )} +
+
+
+ + +
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/index.tsx b/ui/src/components/copilot/config-form/index.tsx new file mode 100644 index 00000000..ec95e3c3 --- /dev/null +++ b/ui/src/components/copilot/config-form/index.tsx @@ -0,0 +1,176 @@ +/** + * Copilot Config Form + * + * Form for configuring GitHub Copilot integration settings. + * Split-view layout matching CLIProxy provider editor: + * - Left (50%): Friendly UI with model mapping selectors + * - Right (50%): Raw JSON editor for copilot.settings.json + */ + +/* eslint-disable react-refresh/only-export-components */ +import { Skeleton } from '@/components/ui/skeleton'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Code2 } from 'lucide-react'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; + +import { HeaderSection } from './header-section'; +import { ModelConfigTab } from './model-config-tab'; +import { SettingsTab } from './settings-tab'; +import { InfoTab } from './info-tab'; +import { RawEditorSection } from './raw-editor-section'; +import { useCopilotConfigForm } from './use-copilot-config-form'; + +export function CopilotConfigForm() { + const { + configLoading, + rawSettingsLoading, + modelsLoading, + isUpdating, + isSavingRawSettings, + models, + rawSettings, + rawJsonContent, + rawJsonEdits, + enabled, + autoStart, + port, + accountType, + currentModel, + rateLimit, + waitOnLimit, + opusModel, + sonnetModel, + haikuModel, + isRawJsonValid, + hasChanges, + conflictDialog, + updateField, + applyPreset, + handleRawJsonChange, + handleSave, + handleConflictResolve, + refetchRawSettings, + } = useCopilotConfigForm(); + + if (configLoading || rawSettingsLoading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ refetchRawSettings()} + onSave={handleSave} + /> + + {/* Split Layout */} +
+ {/* Left Column: Friendly UI */} +
+
+ +
+ + + Model Config + + + Settings + + + Info + + +
+ +
+ updateField('model', model)} + onUpdateOpusModel={(model) => updateField('opusModel', model)} + onUpdateSonnetModel={(model) => updateField('sonnetModel', model)} + onUpdateHaikuModel={(model) => updateField('haikuModel', model)} + /> + + updateField('enabled', v)} + onUpdateAutoStart={(v) => updateField('autoStart', v)} + onUpdatePort={(v) => updateField('port', v)} + onUpdateAccountType={(v) => updateField('accountType', v)} + onUpdateRateLimit={(v) => updateField('rateLimit', v)} + onUpdateWaitOnLimit={(v) => updateField('waitOnLimit', v)} + /> + + +
+
+
+
+ + {/* Right Column: Raw Editor */} +
+
+ + + Raw Configuration (JSON) + +
+ | undefined} + onChange={handleRawJsonChange} + /> +
+
+ + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} + +// Re-export components for external use +export { FlexibleModelSelector } from './model-selector'; +export { UsageCommand } from './usage-command'; +export { FREE_PRESETS, PAID_PRESETS } from './presets'; +export { ModelConfigTab } from './model-config-tab'; +export { SettingsTab } from './settings-tab'; +export { InfoTab } from './info-tab'; +export { RawEditorSection } from './raw-editor-section'; +export { HeaderSection } from './header-section'; +export { useCopilotConfigForm } from './use-copilot-config-form'; +export type { ModelPreset, FlexibleModelSelectorProps } from './types'; diff --git a/ui/src/components/copilot/config-form/info-tab.tsx b/ui/src/components/copilot/config-form/info-tab.tsx new file mode 100644 index 00000000..02973d7b --- /dev/null +++ b/ui/src/components/copilot/config-form/info-tab.tsx @@ -0,0 +1,81 @@ +/** + * Info Tab Content + * Configuration info and quick usage commands + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Badge } from '@/components/ui/badge'; +import { Info } from 'lucide-react'; +import { TabsContent } from '@/components/ui/tabs'; +import { UsageCommand } from './usage-command'; + +interface RawSettings { + path: string; + exists: boolean; + mtime: number; + settings?: Record; +} + +interface InfoTabProps { + rawSettings: RawSettings | undefined; +} + +export function InfoTab({ rawSettings }: InfoTabProps) { + return ( + + +
+
+

+ + Configuration Info +

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

Quick Usage

+
+ + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/model-config-tab.tsx b/ui/src/components/copilot/config-form/model-config-tab.tsx new file mode 100644 index 00000000..ab53250d --- /dev/null +++ b/ui/src/components/copilot/config-form/model-config-tab.tsx @@ -0,0 +1,167 @@ +/** + * Model Config Tab Content + * Presets and model mapping configuration + */ + +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; +import { Sparkles, Zap } from 'lucide-react'; +import { TabsContent } from '@/components/ui/tabs'; +import type { CopilotModel } from '@/hooks/use-copilot'; +import { FREE_PRESETS, PAID_PRESETS } from './presets'; +import { FlexibleModelSelector } from './model-selector'; +import type { ModelPreset } from './types'; + +interface ModelConfigTabProps { + currentModel: string; + opusModel: string; + sonnetModel: string; + haikuModel: string; + models: CopilotModel[]; + modelsLoading: boolean; + onApplyPreset: (preset: ModelPreset) => void; + onUpdateModel: (model: string) => void; + onUpdateOpusModel: (model: string) => void; + onUpdateSonnetModel: (model: string) => void; + onUpdateHaikuModel: (model: string) => void; +} + +export function ModelConfigTab({ + currentModel, + opusModel, + sonnetModel, + haikuModel, + models, + modelsLoading, + onApplyPreset, + onUpdateModel, + onUpdateOpusModel, + onUpdateSonnetModel, + onUpdateHaikuModel, +}: ModelConfigTabProps) { + return ( + + +
+ {/* Quick Presets */} +
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+ + {/* Free Tier Presets */} +
+
+ + Free Tier + + No premium usage count +
+
+ {FREE_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Paid Tier Presets */} +
+
+ + Pro+ Required + + + Uses premium request quota + +
+
+ {PAID_PRESETS.map((preset) => ( + + ))} +
+
+
+ + + + {/* Model Mapping */} +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx new file mode 100644 index 00000000..27638b42 --- /dev/null +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -0,0 +1,91 @@ +/** + * Flexible Model Selector Component + * Dropdown selector for Copilot models with plan badges + */ + +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/components/ui/select'; +import { Check } from 'lucide-react'; +import type { FlexibleModelSelectorProps } from './types'; +import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; + +export function FlexibleModelSelector({ + label, + description, + value, + onChange, + models, + disabled, +}: FlexibleModelSelectorProps) { + // Find current model for display + const currentModel = models.find((m) => m.id === value); + + return ( +
+
+ + {description &&

{description}

} +
+ +
+ ); +} diff --git a/ui/src/components/copilot/config-form/presets.ts b/ui/src/components/copilot/config-form/presets.ts new file mode 100644 index 00000000..e7aad884 --- /dev/null +++ b/ui/src/components/copilot/config-form/presets.ts @@ -0,0 +1,77 @@ +/** + * Model Presets for Copilot Configuration + * Grouped by tier: Free (available to all) and Paid (requires Pro+) + */ + +import type { ModelPreset } from './types'; + +// Note: ALL Claude models require paid Copilot subscription +export const FREE_PRESETS: ModelPreset[] = [ + { + name: 'GPT-4.1 (Free)', + description: 'Free tier - no premium usage', + default: 'gpt-4.1', + opus: 'gpt-4.1', + sonnet: 'gpt-4.1', + haiku: 'gpt-4.1', + }, + { + name: 'GPT-5 Mini (Free)', + description: 'Free tier - lightweight model', + default: 'gpt-5-mini', + opus: 'gpt-5-mini', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', + }, + { + name: 'Raptor Mini (Free)', + description: 'Free tier - fine-tuned for coding', + default: 'raptor-mini', + opus: 'raptor-mini', + sonnet: 'raptor-mini', + haiku: 'raptor-mini', + }, +]; + +export const PAID_PRESETS: ModelPreset[] = [ + { + name: 'Claude Opus 4.5', + description: 'Pro+ (3x) - Most capable reasoning', + default: 'claude-opus-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + { + name: 'Claude Sonnet 4.5', + description: 'Pro+ (1x) - Balanced performance', + default: 'claude-sonnet-4.5', + opus: 'claude-opus-4.5', + sonnet: 'claude-sonnet-4.5', + haiku: 'claude-haiku-4.5', + }, + { + name: 'GPT-5.2', + description: 'Pro+ (1x) - Latest OpenAI (Preview)', + default: 'gpt-5.2', + opus: 'gpt-5.2', + sonnet: 'gpt-5.1', + haiku: 'gpt-5-mini', + }, + { + name: 'GPT-5.1 Codex Max', + description: 'Pro+ (1x) - Best for coding', + default: 'gpt-5.1-codex-max', + opus: 'gpt-5.1-codex-max', + sonnet: 'gpt-5.1-codex', + haiku: 'gpt-5.1-codex-mini', + }, + { + name: 'Gemini 2.5 Pro', + description: 'Pro+ (1x) - Google latest', + default: 'gemini-2.5-pro', + opus: 'gemini-2.5-pro', + sonnet: 'gemini-2.5-pro', + haiku: 'gemini-3-flash', + }, +]; diff --git a/ui/src/components/copilot/config-form/raw-editor-section.tsx b/ui/src/components/copilot/config-form/raw-editor-section.tsx new file mode 100644 index 00000000..8c0694d6 --- /dev/null +++ b/ui/src/components/copilot/config-form/raw-editor-section.tsx @@ -0,0 +1,65 @@ +/** + * Raw Editor Section + * JSON editor panel for copilot settings + */ + +import { Suspense, lazy } from 'react'; +import { Loader2, X } from 'lucide-react'; +import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface RawEditorSectionProps { + rawJsonContent: string; + isRawJsonValid: boolean; + rawJsonEdits: string | null; + rawSettingsEnv: Record | undefined; + onChange: (value: string) => void; +} + +export function RawEditorSection({ + rawJsonContent, + isRawJsonValid, + rawJsonEdits, + rawSettingsEnv, + onChange, +}: RawEditorSectionProps) { + return ( + + + Loading editor... + + } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/settings-tab.tsx b/ui/src/components/copilot/config-form/settings-tab.tsx new file mode 100644 index 00000000..6f630422 --- /dev/null +++ b/ui/src/components/copilot/config-form/settings-tab.tsx @@ -0,0 +1,167 @@ +/** + * Settings Tab Content + * Enable toggle, port, account type, rate limiting, daemon settings + */ + +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 { ScrollArea } from '@/components/ui/scroll-area'; +import { TabsContent } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface SettingsTabProps { + enabled: boolean; + autoStart: boolean; + port: number; + accountType: 'individual' | 'business' | 'enterprise'; + rateLimit: string; + waitOnLimit: boolean; + onUpdateEnabled: (value: boolean) => void; + onUpdateAutoStart: (value: boolean) => void; + onUpdatePort: (value: number) => void; + onUpdateAccountType: (value: 'individual' | 'business' | 'enterprise') => void; + onUpdateRateLimit: (value: string) => void; + onUpdateWaitOnLimit: (value: boolean) => void; +} + +export function SettingsTab({ + enabled, + autoStart, + port, + accountType, + rateLimit, + waitOnLimit, + onUpdateEnabled, + onUpdateAutoStart, + onUpdatePort, + onUpdateAccountType, + onUpdateRateLimit, + onUpdateWaitOnLimit, +}: SettingsTabProps) { + return ( + + +
+ {/* Enable Toggle */} +
+
+ +

+ Allow using GitHub Copilot subscription +

+
+ +
+ + {/* Basic Settings */} +
+

Basic Settings

+ + {/* Port */} +
+ + onUpdatePort(parseInt(e.target.value, 10))} + min={1024} + max={65535} + className="max-w-[150px] h-8" + /> +
+ + {/* Account Type */} +
+ + +
+
+ + + + {/* Rate Limiting */} +
+

Rate Limiting

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

+ Wait instead of error when limit hit +

+
+ +
+
+ + + + {/* Daemon Settings */} +
+

Daemon Settings

+ +
+
+ +

+ Start copilot-api when using profile +

+
+ +
+
+
+
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/types.ts b/ui/src/components/copilot/config-form/types.ts new file mode 100644 index 00000000..7c903c7b --- /dev/null +++ b/ui/src/components/copilot/config-form/types.ts @@ -0,0 +1,25 @@ +/** + * Types for Copilot Config Form + */ + +import type { CopilotModel, CopilotPlanTier } from '@/hooks/use-copilot'; + +export interface FlexibleModelSelectorProps { + label: string; + description?: string; + value: string | undefined; + onChange: (model: string) => void; + models: CopilotModel[]; + disabled?: boolean; +} + +export interface ModelPreset { + name: string; + description: string; + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +export type { CopilotModel, CopilotPlanTier }; diff --git a/ui/src/components/copilot/config-form/usage-command.tsx b/ui/src/components/copilot/config-form/usage-command.tsx new file mode 100644 index 00000000..2309af79 --- /dev/null +++ b/ui/src/components/copilot/config-form/usage-command.tsx @@ -0,0 +1,25 @@ +/** + * Usage Command Component + * Displays a command with copy button + */ + +import { CopyButton } from '@/components/ui/copy-button'; + +interface UsageCommandProps { + label: string; + command: string; +} + +export function UsageCommand({ label, command }: UsageCommandProps) { + return ( +
+ +
+ + {command} + + +
+
+ ); +} diff --git a/ui/src/components/copilot/config-form/use-copilot-config-form.ts b/ui/src/components/copilot/config-form/use-copilot-config-form.ts new file mode 100644 index 00000000..2b6d6cb6 --- /dev/null +++ b/ui/src/components/copilot/config-form/use-copilot-config-form.ts @@ -0,0 +1,193 @@ +/** + * Copilot Config Form Hook + * State management for the copilot config form + */ + +import { useState, useMemo, useCallback } from 'react'; +import { useCopilot } from '@/hooks/use-copilot'; +import { toast } from 'sonner'; +import type { ModelPreset } from './types'; + +export function useCopilotConfigForm() { + const { + config, + configLoading, + models, + modelsLoading, + rawSettings, + rawSettingsLoading, + updateConfigAsync, + isUpdating, + saveRawSettingsAsync, + isSavingRawSettings, + refetchRawSettings, + } = 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; + 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 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, + value: (typeof localOverrides)[K] + ) => { + setLocalOverrides((prev) => ({ ...prev, [key]: value })); + }; + + // Batch update for presets + const applyPreset = (preset: ModelPreset) => { + 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 { + // 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({}); + setRawJsonEdits(null); + toast.success('Copilot configuration saved'); + } catch (error) { + if ((error as Error).message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error('Failed to save settings'); + } + } + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetchRawSettings(); + handleSave(); + } else { + setRawJsonEdits(null); + } + }; + + return { + // Loading states + configLoading, + rawSettingsLoading, + modelsLoading, + isUpdating, + isSavingRawSettings, + + // Data + models, + rawSettings, + rawJsonContent, + rawJsonEdits, + + // Computed values + enabled, + autoStart, + port, + accountType, + currentModel, + rateLimit, + waitOnLimit, + opusModel, + sonnetModel, + haikuModel, + isRawJsonValid, + hasChanges, + + // Dialog state + conflictDialog, + + // Actions + updateField, + applyPreset, + handleRawJsonChange, + handleSave, + handleConflictResolve, + refetchRawSettings, + }; +} diff --git a/ui/src/components/copilot/config-form/utils.ts b/ui/src/components/copilot/config-form/utils.ts new file mode 100644 index 00000000..7fb71aad --- /dev/null +++ b/ui/src/components/copilot/config-form/utils.ts @@ -0,0 +1,32 @@ +/** + * Utility functions for Copilot Config Form + */ + +import type { CopilotPlanTier } from './types'; + +/** Get badge style for plan tier */ +export function getPlanBadgeStyle(plan?: CopilotPlanTier): string { + switch (plan) { + case 'free': + return 'bg-green-100 text-green-700 border-green-200'; + case 'pro': + return 'bg-blue-100 text-blue-700 border-blue-200'; + case 'pro+': + return 'bg-purple-100 text-purple-700 border-purple-200'; + case 'business': + return 'bg-orange-100 text-orange-700 border-orange-200'; + case 'enterprise': + return 'bg-red-100 text-red-700 border-red-200'; + default: + return 'bg-muted text-muted-foreground'; + } +} + +/** Get multiplier display */ +export function getMultiplierDisplay(multiplier?: number): string | null { + if (multiplier === undefined || multiplier === null) return null; + if (multiplier === 0) return 'Free'; + if (multiplier < 1) return `${multiplier}x`; + if (multiplier === 1) return '1x'; + return `${multiplier}x`; +} diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 2311c6b2..e8aa108d 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -1,846 +1,15 @@ /** - * Copilot Config Form - * - * Form for configuring GitHub Copilot integration settings. - * Split-view layout matching CLIProxy provider editor: - * - Left (50%): Friendly UI with model mapping selectors - * - Right (50%): Raw JSON editor for copilot.settings.json + * Copilot Config Form - Re-export from modular structure + * @deprecated Import from '@/components/copilot/config-form' directly */ -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, type CopilotModel, type CopilotPlanTier } from '@/hooks/use-copilot'; -import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; -import { toast } from 'sonner'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { GlobalEnvIndicator } from '@/components/global-env-indicator'; +/* eslint-disable react-refresh/only-export-components */ +export { + CopilotConfigForm, + FlexibleModelSelector, + UsageCommand, + FREE_PRESETS, + PAID_PRESETS, +} from './config-form'; -// Lazy load CodeEditor -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -// Model presets for quick configuration -// Grouped by tier: Free (available to all) and Paid (requires Pro+) -// Note: ALL Claude models require paid Copilot subscription -const FREE_PRESETS = [ - { - name: 'GPT-4.1 (Free)', - description: 'Free tier - no premium usage', - default: 'gpt-4.1', - opus: 'gpt-4.1', - sonnet: 'gpt-4.1', - haiku: 'gpt-4.1', - }, - { - name: 'GPT-5 Mini (Free)', - description: 'Free tier - lightweight model', - default: 'gpt-5-mini', - opus: 'gpt-5-mini', - sonnet: 'gpt-5-mini', - haiku: 'gpt-5-mini', - }, - { - name: 'Raptor Mini (Free)', - description: 'Free tier - fine-tuned for coding', - default: 'raptor-mini', - opus: 'raptor-mini', - sonnet: 'raptor-mini', - haiku: 'raptor-mini', - }, -]; - -const PAID_PRESETS = [ - { - name: 'Claude Opus 4.5', - description: 'Pro+ (3x) - Most capable reasoning', - default: 'claude-opus-4.5', - opus: 'claude-opus-4.5', - sonnet: 'claude-sonnet-4.5', - haiku: 'claude-haiku-4.5', - }, - { - name: 'Claude Sonnet 4.5', - description: 'Pro+ (1x) - Balanced performance', - default: 'claude-sonnet-4.5', - opus: 'claude-opus-4.5', - sonnet: 'claude-sonnet-4.5', - haiku: 'claude-haiku-4.5', - }, - { - name: 'GPT-5.2', - description: 'Pro+ (1x) - Latest OpenAI (Preview)', - default: 'gpt-5.2', - opus: 'gpt-5.2', - sonnet: 'gpt-5.1', - haiku: 'gpt-5-mini', - }, - { - name: 'GPT-5.1 Codex Max', - description: 'Pro+ (1x) - Best for coding', - default: 'gpt-5.1-codex-max', - opus: 'gpt-5.1-codex-max', - sonnet: 'gpt-5.1-codex', - haiku: 'gpt-5.1-codex-mini', - }, - { - name: 'Gemini 2.5 Pro', - description: 'Pro+ (1x) - Google latest', - default: 'gemini-2.5-pro', - opus: 'gemini-2.5-pro', - sonnet: 'gemini-2.5-pro', - haiku: 'gemini-3-flash', - }, -]; - -interface FlexibleModelSelectorProps { - label: string; - description?: string; - value: string | undefined; - onChange: (model: string) => void; - models: CopilotModel[]; - disabled?: boolean; -} - -/** Get badge style for plan tier */ -function getPlanBadgeStyle(plan?: CopilotPlanTier): string { - switch (plan) { - case 'free': - return 'bg-green-100 text-green-700 border-green-200'; - case 'pro': - return 'bg-blue-100 text-blue-700 border-blue-200'; - case 'pro+': - return 'bg-purple-100 text-purple-700 border-purple-200'; - case 'business': - return 'bg-orange-100 text-orange-700 border-orange-200'; - case 'enterprise': - return 'bg-red-100 text-red-700 border-red-200'; - default: - return 'bg-muted text-muted-foreground'; - } -} - -/** Get multiplier display */ -function getMultiplierDisplay(multiplier?: number): string | null { - if (multiplier === undefined || multiplier === null) return null; - if (multiplier === 0) return 'Free'; - if (multiplier < 1) return `${multiplier}x`; - if (multiplier === 1) return '1x'; - return `${multiplier}x`; -} - -function FlexibleModelSelector({ - label, - description, - value, - onChange, - models, - disabled, -}: FlexibleModelSelectorProps) { - // Find current model for display - const currentModel = models.find((m) => m.id === value); - - return ( -
-
- - {description &&

{description}

} -
- -
- ); -} - -export function CopilotConfigForm() { - const { - config, - configLoading, - models, - modelsLoading, - rawSettings, - rawSettingsLoading, - updateConfigAsync, - isUpdating, - saveRawSettingsAsync, - isSavingRawSettings, - refetchRawSettings, - } = 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; - 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 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, - value: (typeof localOverrides)[K] - ) => { - setLocalOverrides((prev) => ({ ...prev, [key]: value })); - }; - - // Batch update for presets - const applyPreset = (preset: (typeof FREE_PRESETS)[0] | (typeof PAID_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 { - // 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({}); - setRawJsonEdits(null); - toast.success('Copilot configuration saved'); - } catch (error) { - if ((error as Error).message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error('Failed to save settings'); - } - } - }; - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - await refetchRawSettings(); - handleSave(); - } else { - setRawJsonEdits(null); - } - }; - - if (configLoading || rawSettingsLoading) { - return ( -
- - - - -
- ); - } - - // Left column: Friendly UI - const renderFriendlyUI = () => ( -
- -
- - - Model Config - - - Settings - - - Info - - -
- -
- {/* Model Config Tab */} - - -
- {/* Quick Presets */} -
-

- - Presets -

-

- Apply pre-configured model mappings -

- - {/* Free Tier Presets */} -
-
- - Free Tier - - - No premium usage count - -
-
- {FREE_PRESETS.map((preset) => ( - - ))} -
-
- - {/* Paid Tier Presets */} -
-
- - Pro+ Required - - - Uses premium request quota - -
-
- {PAID_PRESETS.map((preset) => ( - - ))} -
-
-
- - - - {/* Model Mapping */} -
-

Model Mapping

-

- Configure which models to use for each tier -

-
- updateField('model', model)} - models={models} - disabled={modelsLoading} - /> - updateField('opusModel', model)} - models={models} - disabled={modelsLoading} - /> - updateField('sonnetModel', model)} - models={models} - disabled={modelsLoading} - /> - updateField('haikuModel', model)} - models={models} - disabled={modelsLoading} - /> -
-
-
-
-
- - {/* Settings Tab */} - - -
- {/* Enable Toggle */} -
-
- -

- Allow using GitHub Copilot subscription -

-
- updateField('enabled', v)} - /> -
- - {/* Basic Settings */} -
-

Basic Settings

- - {/* Port */} -
- - 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 -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
-
- ); - - return ( -
- {/* Header */} -
-
-
-
-

Copilot Configuration

- {rawSettings && ( - - copilot.settings.json - - )} -
- {rawSettings && ( -

- Last modified:{' '} - {rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'} -

- )} -
-
-
- - -
-
- - {/* Split Layout - Left panel constrained, Right panel flexible */} -
- {/* Left Column: Friendly UI - constrained width */} -
- {renderFriendlyUI()} -
- - {/* Right Column: Raw Editor - takes remaining space */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> -
- ); -} - -/** Usage command with copy button */ -function UsageCommand({ label, command }: { label: string; command: string }) { - return ( -
- -
- - {command} - - -
-
- ); -} +export type { ModelPreset, FlexibleModelSelectorProps } from './config-form'; diff --git a/ui/src/components/copilot/index.ts b/ui/src/components/copilot/index.ts new file mode 100644 index 00000000..b1c174f5 --- /dev/null +++ b/ui/src/components/copilot/index.ts @@ -0,0 +1,10 @@ +/** + * Copilot Components Barrel Export + */ + +// Main copilot components +export { CopilotStatusCard } from './copilot-status-card'; + +// Config form (from subdirectory) +export { CopilotConfigForm } from './config-form'; +export type { ModelPreset, FlexibleModelSelectorProps } from './config-form';