mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 00:16:46 +00:00
refactor(ui): split copilot-config-form into copilot/config-form/ directory
- extract 846-line component into 13 focused modules - add barrel export in copilot/index.ts
This commit is contained in:
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
|
||||
{rawSettings && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
copilot.settings.json
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified:{' '}
|
||||
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={rawSettingsLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${rawSettingsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={isUpdating || isSavingRawSettings || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{isUpdating || isSavingRawSettings ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<HeaderSection
|
||||
rawSettings={rawSettings}
|
||||
rawSettingsLoading={rawSettingsLoading}
|
||||
isUpdating={isUpdating}
|
||||
isSavingRawSettings={isSavingRawSettings}
|
||||
hasChanges={hasChanges}
|
||||
isRawJsonValid={isRawJsonValid}
|
||||
onRefresh={() => refetchRawSettings()}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* Split Layout */}
|
||||
<div className="flex-1 flex divide-x overflow-hidden">
|
||||
{/* Left Column: Friendly UI */}
|
||||
<div className="w-[540px] shrink-0 flex flex-col overflow-hidden bg-muted/5">
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<ModelConfigTab
|
||||
currentModel={currentModel}
|
||||
opusModel={opusModel}
|
||||
sonnetModel={sonnetModel}
|
||||
haikuModel={haikuModel}
|
||||
models={models}
|
||||
modelsLoading={modelsLoading}
|
||||
onApplyPreset={applyPreset}
|
||||
onUpdateModel={(model) => updateField('model', model)}
|
||||
onUpdateOpusModel={(model) => updateField('opusModel', model)}
|
||||
onUpdateSonnetModel={(model) => updateField('sonnetModel', model)}
|
||||
onUpdateHaikuModel={(model) => updateField('haikuModel', model)}
|
||||
/>
|
||||
|
||||
<SettingsTab
|
||||
enabled={enabled}
|
||||
autoStart={autoStart}
|
||||
port={port}
|
||||
accountType={accountType}
|
||||
rateLimit={rateLimit}
|
||||
waitOnLimit={waitOnLimit}
|
||||
onUpdateEnabled={(v) => 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)}
|
||||
/>
|
||||
|
||||
<InfoTab rawSettings={rawSettings} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Raw Editor */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Raw Configuration (JSON)
|
||||
</span>
|
||||
</div>
|
||||
<RawEditorSection
|
||||
rawJsonContent={rawJsonContent}
|
||||
isRawJsonValid={isRawJsonValid}
|
||||
rawJsonEdits={rawJsonEdits}
|
||||
rawSettingsEnv={rawSettings?.settings?.env as Record<string, string> | undefined}
|
||||
onChange={handleRawJsonChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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';
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
interface InfoTabProps {
|
||||
rawSettings: RawSettings | undefined;
|
||||
}
|
||||
|
||||
export function InfoTab({ rawSettings }: InfoTabProps) {
|
||||
return (
|
||||
<TabsContent value="info" className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
Configuration Info
|
||||
</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Provider</span>
|
||||
<span className="font-mono">GitHub Copilot</span>
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">File Path</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||
{rawSettings.path}
|
||||
</code>
|
||||
<CopyButton value={rawSettings.path} size="icon" className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Status</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
rawSettings.exists
|
||||
? 'w-fit text-green-600 border-green-200 bg-green-50'
|
||||
: 'w-fit text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{rawSettings.exists ? 'File exists' : 'Using defaults'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<UsageCommand label="Run with Copilot" command="ccs copilot" />
|
||||
<UsageCommand label="Authenticate" command="ccs copilot auth" />
|
||||
<UsageCommand label="Start daemon" command="ccs copilot --start" />
|
||||
<UsageCommand label="Stop daemon" command="ccs copilot --stop" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
</p>
|
||||
|
||||
{/* Free Tier Presets */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-green-100 text-green-700 border-green-200"
|
||||
>
|
||||
Free Tier
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">No premium usage count</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FREE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => onApplyPreset(preset)}
|
||||
title={preset.description}
|
||||
>
|
||||
<Zap className="w-3 h-3 text-green-600" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paid Tier Presets */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
|
||||
>
|
||||
Pro+ Required
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Uses premium request quota
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PAID_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => onApplyPreset(preset)}
|
||||
title={preset.description}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={currentModel}
|
||||
onChange={onUpdateModel}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={opusModel || currentModel}
|
||||
onChange={onUpdateOpusModel}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={sonnetModel || currentModel}
|
||||
onChange={onUpdateSonnetModel}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={haikuModel || currentModel}
|
||||
onChange={onUpdateHaikuModel}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
{currentModel?.minPlan && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
|
||||
>
|
||||
{currentModel.minPlan}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">
|
||||
Available Models ({models.length})
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
|
||||
{model.minPlan && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
|
||||
>
|
||||
{model.minPlan}
|
||||
</Badge>
|
||||
)}
|
||||
{model.multiplier !== undefined && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{getMultiplierDisplay(model.multiplier)}
|
||||
</span>
|
||||
)}
|
||||
{model.preview && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
||||
Preview
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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<string, string> | undefined;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function RawEditorSection({
|
||||
rawJsonContent,
|
||||
isRawJsonValid,
|
||||
rawJsonEdits,
|
||||
rawSettingsEnv,
|
||||
onChange,
|
||||
}: RawEditorSectionProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={onChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
<div className="mx-6 mb-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<GlobalEnvIndicator profileEnv={rawSettingsEnv} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
Enable Copilot
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow using GitHub Copilot subscription
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={onUpdateEnabled} />
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Basic Settings</h3>
|
||||
|
||||
{/* Port */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port" className="text-xs">
|
||||
Port
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => onUpdatePort(parseInt(e.target.value, 10))}
|
||||
min={1024}
|
||||
max={65535}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-type" className="text-xs">
|
||||
Account Type
|
||||
</Label>
|
||||
<Select value={accountType} onValueChange={onUpdateAccountType}>
|
||||
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="business">Business</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Rate Limiting</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate-limit" className="text-xs">
|
||||
Rate Limit (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
value={rateLimit}
|
||||
onChange={(e) => onUpdateRateLimit(e.target.value)}
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="wait-on-limit" className="text-xs">
|
||||
Wait on Rate Limit
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Wait instead of error when limit hit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wait-on-limit"
|
||||
checked={waitOnLimit}
|
||||
onCheckedChange={onUpdateWaitOnLimit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Daemon Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Daemon Settings</h3>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-start" className="text-xs">
|
||||
Auto-start Daemon
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Start copilot-api when using profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auto-start" checked={autoStart} onCheckedChange={onUpdateAutoStart} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||
{command}
|
||||
</code>
|
||||
<CopyButton value={command} size="icon" className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 = <K extends keyof typeof localOverrides>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{value}</span>
|
||||
{currentModel?.minPlan && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(currentModel.minPlan)}`}
|
||||
>
|
||||
{currentModel.minPlan}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">
|
||||
Available Models ({models.length})
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.name || model.id}</span>
|
||||
{model.minPlan && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1 py-0 h-4 ${getPlanBadgeStyle(model.minPlan)}`}
|
||||
>
|
||||
{model.minPlan}
|
||||
</Badge>
|
||||
)}
|
||||
{model.multiplier !== undefined && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{getMultiplierDisplay(model.multiplier)}
|
||||
</span>
|
||||
)}
|
||||
{model.preview && (
|
||||
<Badge variant="secondary" className="text-[9px] px-1 py-0 h-4">
|
||||
Preview
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 = <K extends keyof typeof localOverrides>(
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Left column: Friendly UI
|
||||
const renderFriendlyUI = () => (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Model Config Tab */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
</p>
|
||||
|
||||
{/* Free Tier Presets */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-green-100 text-green-700 border-green-200"
|
||||
>
|
||||
Free Tier
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
No premium usage count
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FREE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => applyPreset(preset)}
|
||||
title={preset.description}
|
||||
>
|
||||
<Zap className="w-3 h-3 text-green-600" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paid Tier Presets */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] bg-blue-100 text-blue-700 border-blue-200"
|
||||
>
|
||||
Pro+ Required
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Uses premium request quota
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PAID_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => applyPreset(preset)}
|
||||
title={preset.description}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={currentModel}
|
||||
onChange={(model) => updateField('model', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={opusModel || currentModel}
|
||||
onChange={(model) => updateField('opusModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={sonnetModel || currentModel}
|
||||
onChange={(model) => updateField('sonnetModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={haikuModel || currentModel}
|
||||
onChange={(model) => updateField('haikuModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
Enable Copilot
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow using GitHub Copilot subscription
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => updateField('enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Basic Settings</h3>
|
||||
|
||||
{/* Port */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port" className="text-xs">
|
||||
Port
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value, 10))}
|
||||
min={1024}
|
||||
max={65535}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-type" className="text-xs">
|
||||
Account Type
|
||||
</Label>
|
||||
<Select
|
||||
value={accountType}
|
||||
onValueChange={(v) => updateField('accountType', v as typeof accountType)}
|
||||
>
|
||||
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="business">Business</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Rate Limiting</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate-limit" className="text-xs">
|
||||
Rate Limit (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
value={rateLimit}
|
||||
onChange={(e) => updateField('rateLimit', e.target.value)}
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="wait-on-limit" className="text-xs">
|
||||
Wait on Rate Limit
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Wait instead of error when limit hit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wait-on-limit"
|
||||
checked={waitOnLimit}
|
||||
onCheckedChange={(v) => updateField('waitOnLimit', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Daemon Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Daemon Settings</h3>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-start" className="text-xs">
|
||||
Auto-start Daemon
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Start copilot-api when using profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-start"
|
||||
checked={autoStart}
|
||||
onCheckedChange={(v) => updateField('autoStart', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Info Tab */}
|
||||
<TabsContent
|
||||
value="info"
|
||||
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
Configuration Info
|
||||
</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Provider</span>
|
||||
<span className="font-mono">GitHub Copilot</span>
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">File Path</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||
{rawSettings.path}
|
||||
</code>
|
||||
<CopyButton value={rawSettings.path} size="icon" className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Status</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
rawSettings.exists
|
||||
? 'w-fit text-green-600 border-green-200 bg-green-50'
|
||||
: 'w-fit text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{rawSettings.exists ? 'File exists' : 'Using defaults'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<UsageCommand label="Run with Copilot" command="ccs copilot" />
|
||||
<UsageCommand label="Authenticate" command="ccs copilot auth" />
|
||||
<UsageCommand label="Start daemon" command="ccs copilot --start" />
|
||||
<UsageCommand label="Stop daemon" command="ccs copilot --stop" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Right column: Raw JSON Editor
|
||||
const renderRawEditor = () => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Global Env Indicator */}
|
||||
<div className="mx-6 mb-4">
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<GlobalEnvIndicator profileEnv={rawSettings?.settings?.env} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
|
||||
{rawSettings && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
copilot.settings.json
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified:{' '}
|
||||
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetchRawSettings()}
|
||||
disabled={rawSettingsLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${rawSettingsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isUpdating || isSavingRawSettings || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{isUpdating || isSavingRawSettings ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Layout - Left panel constrained, Right panel flexible */}
|
||||
<div className="flex-1 flex divide-x overflow-hidden">
|
||||
{/* Left Column: Friendly UI - constrained width */}
|
||||
<div className="w-[540px] shrink-0 flex flex-col overflow-hidden bg-muted/5">
|
||||
{renderFriendlyUI()}
|
||||
</div>
|
||||
|
||||
{/* Right Column: Raw Editor - takes remaining space */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Raw Configuration (JSON)
|
||||
</span>
|
||||
</div>
|
||||
{renderRawEditor()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage command with copy button */
|
||||
function UsageCommand({ label, command }: { label: string; command: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||
{command}
|
||||
</code>
|
||||
<CopyButton value={command} size="icon" className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export type { ModelPreset, FlexibleModelSelectorProps } from './config-form';
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user