feat: Integrate Cursor provider support in AI profiles

- Updated AIProfile type to include support for Cursor provider, adding cursorModel and validation logic.
- Enhanced ProfileForm component to handle provider selection and corresponding model configurations for both Claude and Cursor.
- Implemented display functions for model and thinking configurations in ProfileQuickSelect.
- Added default Cursor profiles to the application state.
- Updated UI components to reflect provider-specific settings and validations.
- Marked completion of the AI Profiles Integration phase in the project plan.
This commit is contained in:
Shirone
2025-12-28 01:32:55 +01:00
parent c602314312
commit de11908db1
9 changed files with 355 additions and 61 deletions

View File

@@ -1,9 +1,35 @@
import { Label } from '@/components/ui/label';
import { Brain, UserCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { AgentModel, ThinkingLevel, AIProfile } from '@/store/app-store';
import type { AgentModel, ThinkingLevel, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
* Get display string for a profile's model configuration
*/
function getProfileModelDisplay(profile: AIProfile): string {
if (profile.provider === 'cursor') {
const cursorModel = profile.cursorModel || 'auto';
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
return modelConfig?.label || cursorModel;
}
// Claude
return profile.model || 'sonnet';
}
/**
* Get display string for a profile's thinking configuration
*/
function getProfileThinkingDisplay(profile: AIProfile): string | null {
if (profile.provider === 'cursor') {
// For Cursor, thinking is embedded in the model
return profileHasThinking(profile) ? 'thinking' : null;
}
// Claude
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
}
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: AgentModel;
@@ -23,7 +49,11 @@ export function ProfileQuickSelect({
showManageLink = false,
onManageLinkClick,
}: ProfileQuickSelectProps) {
if (profiles.length === 0) {
// Filter to only Claude profiles for now - Cursor profiles will be supported
// when features support provider selection (Phase 9)
const claudeProfiles = profiles.filter((p) => p.provider === 'claude');
if (claudeProfiles.length === 0) {
return null;
}
@@ -39,7 +69,7 @@ export function ProfileQuickSelect({
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{profiles.slice(0, 6).map((profile) => {
{claudeProfiles.slice(0, 6).map((profile) => {
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isSelected =
selectedModel === profile.model && selectedThinkingLevel === profile.thinkingLevel;
@@ -47,7 +77,7 @@ export function ProfileQuickSelect({
<button
key={profile.id}
type="button"
onClick={() => onSelect(profile.model, profile.thinkingLevel)}
onClick={() => onSelect(profile.model!, profile.thinkingLevel!)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border text-left transition-all',
isSelected
@@ -62,8 +92,8 @@ export function ProfileQuickSelect({
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{profile.name}</p>
<p className="text-[10px] text-muted-foreground truncate">
{profile.model}
{profile.thinkingLevel !== 'none' && ` + ${profile.thinkingLevel}`}
{getProfileModelDisplay(profile)}
{getProfileThinkingDisplay(profile) && ` + ${getProfileThinkingDisplay(profile)}`}
</p>
</div>
</button>

View File

@@ -4,13 +4,20 @@ import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { DialogFooter } from '@/components/ui/dialog';
import { Brain } from 'lucide-react';
import { Brain, Bot, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import type { AIProfile, AgentModel, ThinkingLevel } from '@/store/app-store';
import type {
AIProfile,
AgentModel,
ThinkingLevel,
ModelProvider,
CursorModelId,
} from '@automaker/types';
import { CURSOR_MODEL_MAP, cursorModelHasThinking } from '@automaker/types';
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
import { getProviderFromModel } from '../utils';
interface ProfileFormProps {
profile: Partial<AIProfile>;
@@ -30,13 +37,27 @@ export function ProfileForm({
const [formData, setFormData] = useState({
name: profile.name || '',
description: profile.description || '',
model: profile.model || ('opus' as AgentModel),
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as AgentModel),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
icon: profile.icon || 'Brain',
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const supportsThinking = formData.provider === 'claude' && modelSupportsThinking(formData.model);
const handleProviderChange = (provider: ModelProvider) => {
setFormData({
...formData,
provider,
// Reset to defaults when switching providers
model: provider === 'claude' ? 'sonnet' : formData.model,
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
});
};
const handleModelChange = (model: AgentModel) => {
setFormData({
@@ -45,21 +66,39 @@ export function ProfileForm({
});
};
const handleCursorModelChange = (cursorModel: CursorModelId) => {
setFormData({
...formData,
cursorModel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error('Please enter a profile name');
return;
}
onSave({
const baseProfile = {
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
provider,
provider: formData.provider,
isBuiltIn: false,
icon: formData.icon,
});
};
if (formData.provider === 'cursor') {
onSave({
...baseProfile,
cursorModel: formData.cursorModel,
});
} else {
onSave({
...baseProfile,
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : 'none',
});
}
};
return (
@@ -113,34 +152,128 @@ export function ProfileForm({
</div>
</div>
{/* Model Selection */}
{/* Provider Selection */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
<Label>AI Provider</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-claude"
>
<Bot className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
formData.provider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid="provider-select-cursor"
>
<Terminal className="w-4 h-4" />
Cursor CLI
</button>
</div>
</div>
{/* Thinking Level */}
{supportsThinking && (
{/* Claude Model Selection */}
{formData.provider === 'claude' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Model
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
'flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
formData.model === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`model-select-${id}`}
>
{label.replace('Claude ', '')}
</button>
))}
</div>
</div>
)}
{/* Cursor Model Selection */}
{formData.provider === 'cursor' && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-primary" />
Cursor Model
</Label>
<div className="flex flex-col gap-2">
{Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => (
<button
key={id}
type="button"
onClick={() => handleCursorModelChange(id as CursorModelId)}
className={cn(
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
formData.cursorModel === id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`cursor-model-select-${id}`}
>
<span>{config.label}</span>
<div className="flex gap-1">
{config.hasThinking && (
<Badge
variant="outline"
className={cn(
'text-xs',
formData.cursorModel === id
? 'border-primary-foreground/50 text-primary-foreground'
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
)}
>
Thinking
</Badge>
)}
<Badge
variant={config.tier === 'free' ? 'default' : 'secondary'}
className={cn(
'text-xs',
formData.cursorModel === id && 'bg-primary-foreground/20'
)}
>
{config.tier}
</Badge>
</div>
</button>
))}
</div>
{formData.cursorModel && cursorModelHasThinking(formData.cursorModel) && (
<p className="text-xs text-muted-foreground">
This model has built-in extended thinking capabilities.
</p>
)}
</div>
)}
{/* Claude Thinking Level */}
{formData.provider === 'claude' && supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />

View File

@@ -1,9 +1,10 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { GripVertical, Lock, Pencil, Trash2, Brain } from 'lucide-react';
import { GripVertical, Lock, Pencil, Trash2, Brain, Bot, Terminal } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { AIProfile } from '@/store/app-store';
import type { AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
import { PROFILE_ICONS } from '../constants';
interface SortableProfileCardProps {
@@ -68,12 +69,29 @@ export function SortableProfileCard({ profile, onEdit, onDelete }: SortableProfi
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{profile.description}</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.model}
{/* Provider badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-border text-muted-foreground bg-muted/50 flex items-center gap-1">
{profile.provider === 'cursor' ? (
<Terminal className="w-3 h-3" />
) : (
<Bot className="w-3 h-3" />
)}
{profile.provider === 'cursor' ? 'Cursor' : 'Claude'}
</span>
{profile.thinkingLevel !== 'none' && (
{/* Model badge */}
<span className="text-xs px-2 py-0.5 rounded-full border border-primary/30 text-primary bg-primary/10">
{profile.provider === 'cursor'
? CURSOR_MODEL_MAP[profile.cursorModel || 'auto']?.label ||
profile.cursorModel ||
'auto'
: profile.model || 'sonnet'}
</span>
{/* Thinking badge - works for both providers */}
{profileHasThinking(profile) && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
{profile.provider === 'cursor' ? 'Thinking' : profile.thinkingLevel}
</span>
)}
</div>

View File

@@ -1,6 +1,48 @@
import type { AgentModel, ModelProvider } from '@/store/app-store';
import type { AgentModel, ModelProvider, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
// Helper to determine provider from model
// Helper to determine provider from model (legacy, always returns 'claude')
export function getProviderFromModel(model: AgentModel): ModelProvider {
return 'claude';
}
/**
* Validate an AI profile for completeness and correctness
*/
export function validateProfile(profile: Partial<AIProfile>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Name is required
if (!profile.name?.trim()) {
errors.push('Profile name is required');
}
// Provider must be valid
if (!profile.provider || !['claude', 'cursor'].includes(profile.provider)) {
errors.push('Invalid provider');
}
// Claude-specific validation
if (profile.provider === 'claude') {
if (!profile.model) {
errors.push('Claude model is required');
} else if (!['haiku', 'sonnet', 'opus'].includes(profile.model)) {
errors.push('Invalid Claude model');
}
}
// Cursor-specific validation
if (profile.provider === 'cursor') {
if (profile.cursorModel && !(profile.cursorModel in CURSOR_MODEL_MAP)) {
errors.push('Invalid Cursor model');
}
}
return {
valid: errors.length === 0,
errors,
};
}

View File

@@ -859,6 +859,7 @@ export interface AppActions {
// Default built-in AI profiles
const DEFAULT_AI_PROFILES: AIProfile[] = [
// Claude profiles
{
id: 'profile-heavy-task',
name: 'Heavy Task',
@@ -890,6 +891,34 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
isBuiltIn: true,
icon: 'Zap',
},
// Cursor profiles
{
id: 'profile-cursor-auto',
name: 'Cursor Auto',
description: 'Let Cursor choose the best model automatically.',
provider: 'cursor',
cursorModel: 'auto',
isBuiltIn: true,
icon: 'Sparkles',
},
{
id: 'profile-cursor-fast',
name: 'Cursor Fast',
description: 'Quick responses with GPT-4o Mini via Cursor.',
provider: 'cursor',
cursorModel: 'gpt-4o-mini',
isBuiltIn: true,
icon: 'Zap',
},
{
id: 'profile-cursor-thinking',
name: 'Cursor Thinking',
description: 'Claude Sonnet 4 with extended thinking via Cursor for complex tasks.',
provider: 'cursor',
cursorModel: 'claude-sonnet-4-thinking',
isBuiltIn: true,
icon: 'Brain',
},
];
const initialState: AppState = {

View File

@@ -71,6 +71,8 @@ export {
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
profileHasThinking,
getProfileModelString,
} from './settings.js';
// Model display constants

View File

@@ -7,6 +7,8 @@
*/
import type { AgentModel } from './model.js';
import type { CursorModelId } from './cursor-models.js';
import { CURSOR_MODEL_MAP } from './cursor-models.js';
// Re-export AgentModel for convenience
export type { AgentModel };
@@ -151,16 +153,54 @@ export interface AIProfile {
name: string;
/** User-friendly description */
description: string;
/** Which Claude model to use (opus, sonnet, haiku) */
model: AgentModel;
/** Extended thinking level for reasoning-based tasks */
thinkingLevel: ThinkingLevel;
/** Provider (currently only "claude") */
/** Provider selection: 'claude' or 'cursor' */
provider: ModelProvider;
/** Whether this is a built-in default profile */
isBuiltIn: boolean;
/** Optional icon identifier or emoji */
icon?: string;
// Claude-specific settings
/** Which Claude model to use (opus, sonnet, haiku) - only for Claude provider */
model?: AgentModel;
/** Extended thinking level for reasoning-based tasks - only for Claude provider */
thinkingLevel?: ThinkingLevel;
// Cursor-specific settings
/** Which Cursor model to use - only for Cursor provider
* Note: For Cursor, thinking is embedded in the model ID (e.g., 'claude-sonnet-4-thinking')
*/
cursorModel?: CursorModelId;
}
/**
* Helper to determine if a profile uses thinking mode
*/
export function profileHasThinking(profile: AIProfile): boolean {
if (profile.provider === 'claude') {
return profile.thinkingLevel !== undefined && profile.thinkingLevel !== 'none';
}
if (profile.provider === 'cursor') {
const model = profile.cursorModel || 'auto';
// Check using model map for hasThinking flag, or check for 'thinking' in name
const modelConfig = CURSOR_MODEL_MAP[model];
return modelConfig?.hasThinking ?? false;
}
return false;
}
/**
* Get effective model string for execution
*/
export function getProfileModelString(profile: AIProfile): string {
if (profile.provider === 'cursor') {
return `cursor:${profile.cursorModel || 'auto'}`;
}
// Claude
return profile.model || 'sonnet';
}
/**

View File

@@ -14,7 +14,7 @@
| 5 | [Log Parser Integration](phases/phase-5-log-parser.md) | `completed` | ✅ |
| 6 | [UI Setup Wizard](phases/phase-6-setup-wizard.md) | `completed` | ✅ |
| 7 | [Settings View Provider Tabs](phases/phase-7-settings.md) | `completed` | ✅ |
| 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `pending` | - |
| 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `completed` | |
| 9 | [Task Execution Integration](phases/phase-9-execution.md) | `pending` | - |
| 10 | [Testing & Validation](phases/phase-10-testing.md) | `pending` | - |

View File

@@ -1,6 +1,6 @@
# Phase 8: AI Profiles Integration
**Status:** `pending`
**Status:** `completed`
**Dependencies:** Phase 1 (Types), Phase 7 (Settings)
**Estimated Effort:** Medium (UI + types)
@@ -31,7 +31,7 @@ Extend the AI Profiles system to support Cursor as a provider, with proper handl
### Task 8.1: Update AIProfile Type
**Status:** `pending`
**Status:** `completed`
**File:** `libs/types/src/settings.ts`
@@ -93,7 +93,7 @@ export function getProfileModelString(profile: AIProfile): string {
### Task 8.2: Update Profile Form Component
**Status:** `pending`
**Status:** `completed`
**File:** `apps/ui/src/components/views/profiles-view/components/profile-form.tsx`
@@ -289,7 +289,7 @@ export function ProfileForm({ profile, onSave, onCancel }: ProfileFormProps) {
### Task 8.3: Update Profile Card Display
**Status:** `pending`
**Status:** `completed`
**File:** `apps/ui/src/components/views/profiles-view/components/profile-card.tsx`
@@ -374,7 +374,7 @@ export function ProfileCard({ profile, onEdit, onDelete }: ProfileCardProps) {
### Task 8.4: Add Default Cursor Profiles
**Status:** `pending`
**Status:** `completed`
**File:** `apps/ui/src/components/views/profiles-view/constants.ts`
@@ -430,7 +430,7 @@ export const DEFAULT_PROFILES: AIProfile[] = [
### Task 8.5: Update Profile Validation
**Status:** `pending`
**Status:** `completed`
Add validation for profile data: