diff --git a/ui/public/assets/sidebar/cursor.svg b/ui/public/assets/sidebar/cursor.svg new file mode 100644 index 00000000..a5b2ee3b --- /dev/null +++ b/ui/public/assets/sidebar/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index ddfdad91..aaa04a7d 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -11,8 +11,8 @@ import { BarChart3, Gauge, Github, - Bot, } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; import { Sidebar, SidebarContent, @@ -35,8 +35,34 @@ import { useCliproxyUpdateCheck } from '@/hooks/use-cliproxy'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +interface SidebarBadge { + text: string; + icon: string; +} + +interface SidebarChildItem { + path: string; + label: string; + icon?: LucideIcon; +} + +interface SidebarItem { + path: string; + label: string; + icon?: LucideIcon; + iconSrc?: string; + badge?: SidebarBadge; + isCollapsible?: boolean; + children?: SidebarChildItem[]; +} + +interface SidebarGroupDef { + title: string; + items: SidebarItem[]; +} + // Define navigation groups -const navGroups = [ +const navGroups: SidebarGroupDef[] = [ { title: 'General', items: [ @@ -64,7 +90,7 @@ const navGroups = [ ], }, { path: '/copilot', icon: Github, label: 'GitHub Copilot' }, - { path: '/cursor', icon: Bot, label: 'Cursor IDE' }, + { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: 'Cursor IDE' }, { path: '/accounts', icon: Users, @@ -114,6 +140,17 @@ export function AppSidebar() { ); }; + const renderMenuIcon = (item: Pick) => { + if (item.iconSrc) { + return ; + } + if (item.icon) { + const Icon = item.icon; + return ; + } + return null; + }; + return ( @@ -141,7 +178,7 @@ export function AppSidebar() { isActive={isParentActive(item.children)} onClick={() => navigate(item.path)} > - {item.icon && } + {renderMenuIcon(item)} {getItemLabel(item)} @@ -173,7 +210,7 @@ export function AppSidebar() { tooltip={getItemLabel(item)} > - {item.icon && } + {renderMenuIcon(item)} {getItemLabel(item)} diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index e7d43976..20ed9be8 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -26,6 +26,10 @@ export interface CursorConfig { port: number; auto_start: boolean; ghost_mode: boolean; + model: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; } export interface CursorModel { diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 40d35a9f..d37a0c2d 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -6,9 +6,9 @@ import { useMemo, useState, type ElementType } from 'react'; import { toast } from 'sonner'; import { - Bot, + AlertTriangle, CheckCircle2, - FileCode, + Code2, Key, Loader2, Play, @@ -18,19 +18,30 @@ import { Save, Server, ShieldCheck, + Sparkles, + Zap, XCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useCursor } from '@/hooks/use-cursor'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { CodeEditor } from '@/components/shared/code-editor'; +import { Separator } from '@/components/ui/separator'; +import { RawEditorSection } from '@/components/copilot/config-form/raw-editor-section'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Dialog, DialogContent, @@ -44,20 +55,166 @@ interface CursorConfigDraft { port: string; auto_start: boolean; ghost_mode: boolean; + model: string; + opus_model: string; + sonnet_model: string; + haiku_model: string; +} + +interface RawSettingsParseResult { + isValid: boolean; + settings?: { env?: Record }; + error?: string; } function buildConfigDraft(config?: { port?: number; auto_start?: boolean; ghost_mode?: boolean; + model?: string; + opus_model?: string; + sonnet_model?: string; + haiku_model?: string; }): CursorConfigDraft { return { port: String(config?.port ?? 20129), auto_start: config?.auto_start ?? false, ghost_mode: config?.ghost_mode ?? true, + model: config?.model?.trim() || 'gpt-5.3-codex', + opus_model: config?.opus_model?.trim() || '', + sonnet_model: config?.sonnet_model?.trim() || '', + haiku_model: config?.haiku_model?.trim() || '', }; } +function pickModelByPatterns( + models: Array<{ id: string }>, + patterns: RegExp[], + fallback: string +): string { + const matched = models.find((model) => patterns.some((pattern) => pattern.test(model.id))); + return matched?.id ?? fallback; +} + +function normalizeModelKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function pickModelByAliases( + models: Array<{ id: string; name: string }>, + aliases: string[], + fallback: string +): string { + const normalizedAliasSet = new Set(aliases.map(normalizeModelKey)); + const direct = models.find((model) => normalizedAliasSet.has(normalizeModelKey(model.id))); + if (direct) return direct.id; + + const byName = models.find((model) => normalizedAliasSet.has(normalizeModelKey(model.name))); + if (byName) return byName.id; + + return fallback; +} + +function parseRawSettings(value: string): RawSettingsParseResult { + try { + const parsed = JSON.parse(value || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + isValid: false, + error: 'Raw settings must be a JSON object', + }; + } + + return { + isValid: true, + settings: parsed as { env?: Record }, + }; + } catch (error) { + return { + isValid: false, + error: (error as Error).message || 'Invalid JSON', + }; + } +} + +function CursorModelSelector({ + label, + description, + value, + models, + disabled, + allowDefaultFallback = false, + onChange, +}: { + label: string; + description: string; + value: string; + models: Array<{ id: string; name: string; provider: string }>; + disabled?: boolean; + allowDefaultFallback?: boolean; + onChange: (value: string) => void; +}) { + const selectorValue = value || (allowDefaultFallback ? '__default' : ''); + const selected = models.find((model) => model.id === value); + + return ( +
+
+ +

{description}

+
+ +
+ ); +} + function StatusItem({ icon: Icon, label, @@ -131,11 +288,47 @@ export function CursorPage() { const effectivePort = configDirty ? configDraft.port : pristineConfigDraft.port; const effectiveAutoStart = configDirty ? configDraft.auto_start : pristineConfigDraft.auto_start; const effectiveGhostMode = configDirty ? configDraft.ghost_mode : pristineConfigDraft.ghost_mode; + const effectiveModel = configDirty ? configDraft.model : pristineConfigDraft.model; + const effectiveOpusModel = configDirty ? configDraft.opus_model : pristineConfigDraft.opus_model; + const effectiveSonnetModel = configDirty + ? configDraft.sonnet_model + : pristineConfigDraft.sonnet_model; + const effectiveHaikuModel = configDirty + ? configDraft.haiku_model + : pristineConfigDraft.haiku_model; const effectiveRawConfigText = rawConfigDirty ? rawConfigText : JSON.stringify(rawSettings?.settings ?? {}, null, 2); const rawSettingsReady = Boolean(rawSettings); - const disableRawSave = isSavingRawSettings || rawSettingsLoading || !rawSettingsReady; + const rawParseResult = useMemo( + () => parseRawSettings(effectiveRawConfigText), + [effectiveRawConfigText] + ); + const isRawJsonValid = rawParseResult.isValid; + const hasChanges = configDirty || rawConfigDirty; + const canSave = !rawConfigDirty || (rawSettingsReady && isRawJsonValid); + const orderedModels = useMemo(() => { + const seen = new Set(); + const sorted = [...models].sort((a, b) => a.name.localeCompare(b.name)); + const deduped = sorted.filter((model) => { + if (seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); + + if (effectiveModel && !sorted.some((model) => model.id === effectiveModel)) { + return [ + { + id: effectiveModel, + name: effectiveModel, + provider: 'custom', + }, + ...deduped, + ]; + } + + return deduped; + }, [models, effectiveModel]); const updateConfigDraft = (updater: (draft: CursorConfigDraft) => CursorConfigDraft) => { setConfigDraft((previousDraft) => { @@ -151,11 +344,17 @@ export function CursorPage() { [status?.enabled] ); - const handleSaveConfig = async () => { + const handleSaveConfig = async ({ + suppressSuccessToast = false, + }: { suppressSuccessToast?: boolean } = {}) => { const parsedPort = Number(effectivePort); if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { toast.error('Port must be an integer between 1 and 65535'); - return; + return false; + } + if (!effectiveModel.trim()) { + toast.error('Default model is required'); + return false; } try { @@ -163,6 +362,10 @@ export function CursorPage() { port: parsedPort, auto_start: effectiveAutoStart, ghost_mode: effectiveGhostMode, + model: effectiveModel, + opus_model: effectiveOpusModel || undefined, + sonnet_model: effectiveSonnetModel || undefined, + haiku_model: effectiveHaikuModel || undefined, }); setConfigDirty(false); setConfigDraft( @@ -170,14 +373,104 @@ export function CursorPage() { port: parsedPort, auto_start: effectiveAutoStart, ghost_mode: effectiveGhostMode, + model: effectiveModel, + opus_model: effectiveOpusModel || undefined, + sonnet_model: effectiveSonnetModel || undefined, + haiku_model: effectiveHaikuModel || undefined, }) ); - toast.success('Cursor config saved'); + if (!suppressSuccessToast) { + toast.success('Cursor config and model mapping saved'); + } + return true; } catch (error) { toast.error((error as Error).message || 'Failed to save config'); + return false; } }; + const applyPreset = (preset: 'codex53' | 'claude46' | 'gemini3') => { + const fallbackModel = effectiveModel || currentModel || models[0]?.id || 'gpt-5.3-codex'; + const codex53 = pickModelByAliases( + models, + ['gpt-5.3-codex', 'gpt53codex', 'GPT-5.3 Codex'], + pickModelByPatterns(models, [/gpt[-.]?5.*codex/i], fallbackModel) + ); + const codexMax = pickModelByAliases( + models, + ['gpt-5.1-codex-max', 'gpt51codexmax', 'GPT-5.1 Codex Max'], + pickModelByPatterns(models, [/gpt[-.]?5.*codex.*max/i], codex53) + ); + const codexFast = pickModelByAliases( + models, + ['gpt-5-fast', 'gpt5fast', 'GPT-5 Fast'], + pickModelByPatterns(models, [/gpt[-.]?5.*fast/i], codex53) + ); + const codexMini = pickModelByAliases( + models, + ['gpt-5-mini', 'gpt5mini', 'GPT-5 Mini'], + pickModelByPatterns(models, [/gpt[-.]?5.*mini/i], codexFast) + ); + const opus46 = pickModelByAliases( + models, + ['claude-4.6-opus', 'claude46opus', 'Claude 4.6 Opus'], + pickModelByPatterns(models, [/claude[-.]?4\.?6.*opus/i, /claude.*opus/i], codex53) + ); + const sonnet45 = pickModelByAliases( + models, + ['claude-4.5-sonnet', 'claude45sonnet', 'Claude 4.5 Sonnet'], + pickModelByPatterns(models, [/claude[-.]?4\.?5.*sonnet/i, /claude.*sonnet/i], codex53) + ); + const haiku45 = pickModelByAliases( + models, + ['claude-4.5-haiku', 'claude45haiku', 'Claude 4.5 Haiku'], + pickModelByPatterns(models, [/claude[-.]?4\.?5.*haiku/i, /haiku/i], sonnet45) + ); + const gemini3Pro = pickModelByAliases( + models, + ['gemini-3-pro', 'gemini3pro', 'Gemini 3 Pro'], + pickModelByPatterns(models, [/gemini[-.]?3.*pro/i], codex53) + ); + const gemini3Flash = pickModelByAliases( + models, + ['gemini-3-flash', 'gemini3flash', 'Gemini 3 Flash'], + pickModelByPatterns(models, [/gemini[-.]?3.*flash/i, /gemini[-.]?2\.?5.*flash/i], gemini3Pro) + ); + + if (preset === 'codex53') { + updateConfigDraft((draft) => ({ + ...draft, + model: codex53, + opus_model: codexMax, + sonnet_model: codex53, + haiku_model: codexMini, + })); + toast.success('Applied GPT-5.3 Codex preset'); + return; + } + + if (preset === 'claude46') { + updateConfigDraft((draft) => ({ + ...draft, + model: opus46, + opus_model: opus46, + sonnet_model: sonnet45, + haiku_model: haiku45, + })); + toast.success('Applied Claude 4.6 preset'); + return; + } + + updateConfigDraft((draft) => ({ + ...draft, + model: gemini3Pro, + opus_model: gemini3Pro, + sonnet_model: gemini3Pro, + haiku_model: gemini3Flash, + })); + toast.success('Applied Gemini 3 preset'); + }; + const handleToggleEnabled = async (enabled: boolean) => { try { await updateConfigAsync({ enabled }); @@ -242,32 +535,29 @@ export function CursorPage() { } }; - const handleSaveRawSettings = async () => { + const handleSaveRawSettings = async ({ + suppressSuccessToast = false, + }: { suppressSuccessToast?: boolean } = {}) => { if (!rawSettingsReady) { toast.error('Raw settings are still loading. Please wait and try again.'); - return; + return false; } - let parsed: unknown; - try { - parsed = JSON.parse(effectiveRawConfigText || '{}'); - } catch (error) { - toast.error((error as Error).message || 'Invalid JSON'); - return; - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - toast.error('Raw settings must be a JSON object'); - return; + if (!rawParseResult.isValid || !rawParseResult.settings) { + toast.error(rawParseResult.error || 'Invalid JSON'); + return false; } try { await saveRawSettingsAsync({ - settings: parsed as { env?: Record }, + settings: rawParseResult.settings, expectedMtime: rawSettings?.mtime, }); setRawConfigDirty(false); - toast.success('Raw settings saved'); + if (!suppressSuccessToast) { + toast.success('Raw settings saved'); + } + return true; } catch (error) { const message = (error as Error).message || 'Failed to save raw settings'; if (message === 'CONFLICT') { @@ -275,9 +565,41 @@ export function CursorPage() { } else { toast.error(message); } + return false; } }; + const handleSaveAll = async () => { + if (!hasChanges) return; + + const saveConfig = configDirty; + const saveRawSettings = rawConfigDirty; + + if (saveConfig) { + const saved = await handleSaveConfig({ + suppressSuccessToast: saveRawSettings, + }); + if (!saved) return; + } + + if (saveRawSettings) { + const saved = await handleSaveRawSettings({ + suppressSuccessToast: saveConfig, + }); + if (!saved) return; + } + + if (saveConfig && saveRawSettings) { + toast.success('Cursor configuration saved'); + } + }; + + const handleHeaderRefresh = () => { + setRawConfigDirty(false); + refetchStatus(); + refetchRawSettings(); + }; + return ( <>
@@ -285,7 +607,11 @@ export function CursorPage() {
- +

Cursor

{integrationBadge}
@@ -304,6 +630,20 @@ export function CursorPage() {
+
+
+ + + Unofficial API - Use at Your Own Risk + +
+
    +
  • Reverse-engineered integration may break anytime
  • +
  • Abuse or excessive usage may risk account restrictions
  • +
  • No warranty, no responsibility from CCS
  • +
+
+
-
- - - Config - Models - Raw Settings - +
+
+
+
+
+

Cursor Configuration

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

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

+ )} +
+
+
+ + +
+
- - - - Cursor Runtime Config - - Controls daemon behavior for local Cursor OpenAI-compatible endpoint. - - - -
- - { - updateConfigDraft((draft) => ({ ...draft, port: e.target.value })); - }} - /> -
+
+
+ +
+ + + Model Config + + + Settings + + + Info + + +
-
-
- -

- Start cursor daemon automatically when integration is used. -

-
- { - updateConfigDraft((draft) => ({ ...draft, auto_start: value })); - }} - /> -
- -
-
- -

- Requests `x-ghost-mode` to reduce telemetry. -

-
- { - updateConfigDraft((draft) => ({ ...draft, ghost_mode: value })); - }} - /> -
- -
- -
- - - - - - - - Available Models - - Cursor forwards the requested model from each client request. - - - - {modelsLoading ? ( -
- - Loading models... -
- ) : ( -
- {models.map((model) => ( -
-
-

{model.id}

-

- {model.name} • {model.provider} -

-
-
- {model.id === currentModel && Default} +
+ + +
+
+

+ + Presets +

+

+ Apply pre-configured model mappings +

+
+ + +
- ))} -
- )} - - -
- - - - - - cursor.settings.json - - - {rawSettings?.path ?? '~/.ccs/cursor.settings.json'} - - - - { - setRawConfigDirty(true); - setRawConfigText(value); - }} - language="json" - /> + -
- - -
-
-
-
- +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ { + updateConfigDraft((draft) => ({ ...draft, model: value })); + }} + /> + { + updateConfigDraft((draft) => ({ ...draft, opus_model: value })); + }} + /> + { + updateConfigDraft((draft) => ({ ...draft, sonnet_model: value })); + }} + /> + { + updateConfigDraft((draft) => ({ ...draft, haiku_model: value })); + }} + /> +
+
+
+ + + + + +
+
+

Runtime Settings

+ +
+ + { + updateConfigDraft((draft) => ({ + ...draft, + port: e.target.value, + })); + }} + /> +
+ +
+
+ +

+ Start Cursor daemon automatically when integration is used. +

+
+ { + updateConfigDraft((draft) => ({ ...draft, auto_start: value })); + }} + /> +
+ +
+
+ +

+ Request `x-ghost-mode` to reduce telemetry. +

+
+ { + updateConfigDraft((draft) => ({ ...draft, ghost_mode: value })); + }} + /> +
+
+
+
+
+ + + +
+
+

Available Models

+ {modelsLoading ? ( +
+ + Loading models... +
+ ) : models.length === 0 ? ( +

+ No model metadata available yet. Start daemon and refresh. +

+ ) : ( +
+ {models.map((model) => ( +
+
+

{model.id}

+

+ {model.name} • {model.provider} +

+
+ {model.id === currentModel && Default} +
+ ))} +
+ )} +
+ +
+
+ Provider + Cursor IDE +
+
+ File Path + + {rawSettings?.path ?? '~/.ccs/cursor.settings.json'} + +
+

+ Model mapping writes `ANTHROPIC_MODEL`, + `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and + `ANTHROPIC_DEFAULT_HAIKU_MODEL` in `cursor.settings.json`. +

+
+
+
+
+
+ +
+ +
+
+ + + Raw Configuration (JSON) + +
+ { + setRawConfigDirty(true); + setRawConfigText(value); + }} + /> +
+