diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts new file mode 100644 index 000000000..b6fe509c2 --- /dev/null +++ b/apps/server/src/providers/cursor-provider.ts @@ -0,0 +1,522 @@ +/** + * Cursor Provider - Executes queries using cursor-agent CLI + * + * Wraps the cursor-agent CLI tool for seamless integration + * with the provider architecture. + */ + +import { spawn, exec, type ChildProcess } from 'child_process'; +import { promisify } from 'util'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { BaseProvider } from './base-provider.js'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; + +const execAsync = promisify(exec); + +/** + * Parsed JSON message from cursor-agent --output-format stream-json + * Format matches Claude SDK closely with types: system, user, thinking, assistant, result + */ +interface CursorStreamMessage { + type: 'system' | 'user' | 'thinking' | 'assistant' | 'result' | 'error'; + subtype?: 'init' | 'delta' | 'completed' | 'success' | 'error'; + session_id?: string; + message?: { + role: 'user' | 'assistant'; + content: Array<{ + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; + }>; + }; + // For thinking messages + text?: string; + // For result messages + result?: string; + is_error?: boolean; + duration_ms?: number; + // For error messages + error?: string; +} + +export class CursorProvider extends BaseProvider { + private cachedCliPath: string | null = null; + private cliPathChecked = false; + + getName(): string { + return 'cursor'; + } + + /** + * Find the cursor-agent CLI path, checking PATH first then common locations + */ + async findCliPath(): Promise { + if (this.cliPathChecked) { + return this.cachedCliPath; + } + const isWindows = process.platform === 'win32'; + // Try to find in PATH first + try { + const findCommand = isWindows ? 'where cursor-agent' : 'which cursor-agent'; + const { stdout } = await execAsync(findCommand); + const foundPath = stdout.trim().split(/\r?\n/)[0]; + if (foundPath) { + this.cachedCliPath = foundPath; + this.cliPathChecked = true; + return foundPath; + } + } catch { + // Not in PATH, try common locations + } + // Check common installation paths + const commonPaths = isWindows + ? (() => { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return [ + path.join(os.homedir(), '.local', 'bin', 'cursor-agent.exe'), + path.join(appData, 'npm', 'cursor-agent.cmd'), + path.join(appData, 'npm', 'cursor-agent'), + ]; + })() + : [ + path.join(os.homedir(), '.local', 'bin', 'cursor-agent'), + '/usr/local/bin/cursor-agent', + path.join(os.homedir(), '.npm-global', 'bin', 'cursor-agent'), + ]; + for (const p of commonPaths) { + try { + await fs.access(p); + this.cachedCliPath = p; + this.cliPathChecked = true; + return p; + } catch { + // Not found at this path + } + } + this.cliPathChecked = true; + return null; + } + + /** Get the CLI command to use (path or fallback to 'cursor-agent') */ + private async getCliCommand(): Promise { + const cliPath = await this.findCliPath(); + return cliPath || 'cursor-agent'; + } + + /** + * Execute a query using cursor-agent CLI + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model, + cwd, + abortController, + sdkSessionId, // Used as chatId for --resume + } = options; + + // Build CLI arguments + const args: string[] = []; + + // Use print mode for non-interactive output + args.push('--print'); + + // Use stream-json for structured streaming + args.push('--output-format', 'stream-json'); + args.push('--stream-partial-output'); + + // Set workspace + args.push('--workspace', cwd); + + // Set model if provided - map to cursor-agent expected format + if (model) { + const cursorModel = this.mapModelToCursorFormat(model); + args.push('--model', cursorModel); + } + + // Force mode to auto-approve tool use (similar to Claude's permissionMode) + args.push('--force'); + + // Resume existing chat if we have a session ID + if (sdkSessionId) { + args.push('--resume', sdkSessionId); + } + + // Add the prompt + const promptText = Array.isArray(prompt) + ? prompt + .filter((p) => p.type === 'text') + .map((p) => p.text) + .join('\n') + : prompt; + + args.push(promptText); + + // Get CLI command (uses cached path or finds it) + const cliCommand = await this.getCliCommand(); + + // Spawn cursor-agent process + const proc = spawn(cliCommand, args, { + cwd, + env: { + ...process.env, + // Pass API key if configured + ...(this.config.apiKey ? { CURSOR_API_KEY: this.config.apiKey } : {}), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Handle abort + if (abortController) { + abortController.signal.addEventListener('abort', () => { + proc.kill('SIGTERM'); + }); + } + + // Process streaming output + yield* this.processStream(proc); + } + + /** + * Process the streaming JSON output from cursor-agent + */ + private async *processStream(proc: ChildProcess): AsyncGenerator { + let buffer = ''; + let sessionId: string | undefined; + + const stdout = proc.stdout; + if (!stdout) { + throw new Error('Failed to get stdout from cursor-agent process'); + } + + // Collect stderr for error reporting + let stderrOutput = ''; + proc.stderr?.on('data', (chunk: Buffer) => { + stderrOutput += chunk.toString(); + }); + + // Set up close promise early to avoid missing fast exits + const closePromise = new Promise((resolve, reject) => { + proc.on('close', (code) => { + if (code !== 0 && code !== null) { + reject(new Error(`cursor-agent exited with code ${code}: ${stderrOutput}`)); + } else { + resolve(); + } + }); + proc.on('error', reject); + }); + + try { + for await (const chunk of stdout) { + buffer += chunk.toString(); + + // Process complete JSON lines (newline-delimited JSON) + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const msg: CursorStreamMessage = JSON.parse(trimmed); + + // Capture session_id for continuity + if (msg.session_id) { + sessionId = msg.session_id; + } + + const providerMsg = this.convertToProviderMessage(msg); + if (providerMsg) { + yield providerMsg; + } + } catch { + // If not valid JSON, log and skip + console.warn('[CursorProvider] Failed to parse line:', trimmed); + } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + try { + const msg: CursorStreamMessage = JSON.parse(buffer.trim()); + const providerMsg = this.convertToProviderMessage(msg); + if (providerMsg) { + yield providerMsg; + } + } catch { + console.warn('[CursorProvider] Failed to parse remaining buffer:', buffer.trim()); + } + } + + // Wait for process to exit + await closePromise; + } catch (error) { + // Emit error message + yield { + type: 'error', + session_id: sessionId, + error: (error as Error).message, + }; + throw error; + } + } + + /** + * Convert cursor-agent JSON message to ProviderMessage format + * The cursor-agent format is very similar to Claude SDK format + */ + private convertToProviderMessage(msg: CursorStreamMessage): ProviderMessage | null { + switch (msg.type) { + case 'system': + // System init message - we can skip or use for logging + return null; + + case 'user': + // User message echo - pass through + return { + type: 'user', + session_id: msg.session_id, + message: msg.message + ? { + role: 'user', + content: msg.message.content.map((c) => ({ + type: c.type as 'text' | 'tool_use' | 'thinking' | 'tool_result', + text: c.text, + name: c.name, + input: c.input, + tool_use_id: c.tool_use_id, + content: c.content, + })), + } + : undefined, + }; + + case 'thinking': + // Thinking messages - convert to assistant with thinking block + if (msg.subtype === 'delta' && msg.text) { + return { + type: 'assistant', + session_id: msg.session_id, + message: { + role: 'assistant', + content: [{ type: 'thinking', thinking: msg.text }], + }, + }; + } + // thinking completed - skip + return null; + + case 'assistant': + // Assistant response - direct mapping + return { + type: 'assistant', + session_id: msg.session_id, + message: msg.message + ? { + role: 'assistant', + content: msg.message.content.map((c) => ({ + type: c.type as 'text' | 'tool_use' | 'thinking' | 'tool_result', + text: c.text, + name: c.name, + input: c.input, + tool_use_id: c.tool_use_id, + content: c.content, + })), + } + : undefined, + }; + + case 'result': + // Final result + return { + type: 'result', + subtype: msg.is_error ? 'error' : 'success', + session_id: msg.session_id, + result: msg.result, + }; + + case 'error': + return { + type: 'error', + session_id: msg.session_id, + error: msg.error, + }; + + default: + return null; + } + } + + /** + * Detect cursor-agent CLI installation + */ + async detectInstallation(): Promise { + const cliPath = await this.findCliPath(); + if (!cliPath) { + return { + installed: false, + method: 'cli', + error: 'cursor-agent CLI not found in PATH or common locations', + }; + } + const hasApiKey = !!process.env.CURSOR_API_KEY || !!this.config.apiKey; + let version = ''; + try { + const result = await this.runCommand(['-v']); + version = result.trim(); + } catch { + // Version command might not be available + } + let authenticated = false; + try { + const statusResult = await this.runCommand(['status']); + authenticated = + !statusResult.toLowerCase().includes('not logged in') && + !statusResult.toLowerCase().includes('error') && + !statusResult.toLowerCase().includes('not authenticated'); + } catch { + // Status check failed, assume not authenticated + } + // Environment variable API key overrides + if (process.env.CURSOR_API_KEY) { + authenticated = true; + } + return { + installed: true, + method: 'cli', + path: cliPath, + version, + hasApiKey, + authenticated, + }; + } + + /** + * Run a cursor-agent command and return output + */ + private async runCommand(args: string[]): Promise { + const cliCommand = await this.getCliCommand(); + return new Promise((resolve, reject) => { + const proc = spawn(cliCommand, args, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + proc.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || `Command failed with code ${code}`)); + } + }); + proc.on('error', (err) => { + reject(err); + }); + }); + } + + /** + * Get available Cursor models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: 'cursor-opus-4.5-thinking', + name: 'Cursor Opus 4.5 Thinking', + modelString: 'opus-4.5-thinking', + provider: 'cursor', + description: 'Claude Opus 4.5 with extended thinking via Cursor', + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: 'premium', + default: true, + }, + { + id: 'cursor-sonnet-4.5', + name: 'Cursor Sonnet 4.5', + modelString: 'sonnet-4.5', + provider: 'cursor', + description: 'Claude Sonnet 4.5 via Cursor', + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: 'standard', + }, + { + id: 'cursor-gpt-5.2', + name: 'Cursor GPT-5.2', + modelString: 'gpt-5.2', + provider: 'cursor', + description: 'OpenAI GPT-5.2 via Cursor', + contextWindow: 128000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: 'premium', + }, + { + id: 'cursor-composer', + name: 'Cursor Composer', + modelString: 'composer', + provider: 'cursor', + description: 'Cursor Composer model', + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: 'premium', + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ['tools', 'text', 'vision']; + return supportedFeatures.includes(feature); + } + + /** + * Map model ID to cursor-agent expected format + * e.g., 'cursor-sonnet' -> 'sonnet-4.5', 'cursor-opus-thinking' -> 'opus-4.5-thinking' + */ + private mapModelToCursorFormat(model: string): string { + const modelMap: Record = { + // UI alias -> cursor-agent format + 'cursor-opus-thinking': 'opus-4.5-thinking', + 'cursor-sonnet': 'sonnet-4.5', + 'cursor-gpt5': 'gpt-5.2', + 'cursor-composer': 'composer', + // Full IDs -> cursor-agent format + 'cursor-opus-4.5-thinking': 'opus-4.5-thinking', + 'cursor-sonnet-4.5': 'sonnet-4.5', + 'cursor-gpt-5.2': 'gpt-5.2', + }; + + const lowerModel = model.toLowerCase(); + return modelMap[lowerModel] || model; + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 0ef9b36ea..f546b00c0 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -8,6 +8,7 @@ import { BaseProvider } from './base-provider.js'; import { ClaudeProvider } from './claude-provider.js'; +import { CursorProvider } from './cursor-provider.js'; import type { InstallationStatus } from './types.js'; export class ProviderFactory { @@ -25,10 +26,12 @@ export class ProviderFactory { return new ClaudeProvider(); } + // Cursor models (cursor-*) + if (lowerModel.startsWith('cursor-')) { + return new CursorProvider(); + } + // Future providers: - // if (lowerModel.startsWith("cursor-")) { - // return new CursorProvider(); - // } // if (lowerModel.startsWith("opencode-")) { // return new OpenCodeProvider(); // } @@ -42,10 +45,7 @@ export class ProviderFactory { * Get all available providers */ static getAllProviders(): BaseProvider[] { - return [ - new ClaudeProvider(), - // Future providers... - ]; + return [new ClaudeProvider(), new CursorProvider()]; } /** @@ -80,9 +80,10 @@ export class ProviderFactory { case 'anthropic': return new ClaudeProvider(); + case 'cursor': + return new CursorProvider(); + // Future providers: - // case "cursor": - // return new CursorProvider(); // case "opencode": // return new OpenCodeProvider(); diff --git a/apps/server/src/routes/setup/get-cursor-status.ts b/apps/server/src/routes/setup/get-cursor-status.ts new file mode 100644 index 000000000..4b4ee82c0 --- /dev/null +++ b/apps/server/src/routes/setup/get-cursor-status.ts @@ -0,0 +1,41 @@ +/** + * Business logic for getting Cursor CLI status + * Uses CursorProvider as the single source of truth for CLI detection + */ + +import { CursorProvider } from '../../providers/cursor-provider.js'; + +export interface CursorStatus { + status: 'installed' | 'not_installed'; + installed: boolean; + method: string; + version: string; + path: string; + auth: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; +} + +export async function getCursorStatus(): Promise { + const provider = new CursorProvider({}); + const installStatus = await provider.detectInstallation(); + // Determine auth method + let authMethod = 'none'; + if (installStatus.authenticated) { + authMethod = process.env.CURSOR_API_KEY ? 'api_key_env' : 'oauth'; + } + return { + status: installStatus.installed ? 'installed' : 'not_installed', + installed: installStatus.installed, + method: installStatus.method || 'none', + version: installStatus.version || '', + path: installStatus.path || '', + auth: { + authenticated: installStatus.authenticated || false, + method: authMethod, + hasApiKey: installStatus.hasApiKey || false, + }, + }; +} diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 3681b2fc5..d76385b41 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -4,6 +4,7 @@ import { Router } from 'express'; import { createClaudeStatusHandler } from './routes/claude-status.js'; +import { createCursorStatusHandler } from './routes/cursor-status.js'; import { createInstallClaudeHandler } from './routes/install-claude.js'; import { createAuthClaudeHandler } from './routes/auth-claude.js'; import { createStoreApiKeyHandler } from './routes/store-api-key.js'; @@ -17,6 +18,7 @@ export function createSetupRoutes(): Router { const router = Router(); router.get('/claude-status', createClaudeStatusHandler()); + router.get('/cursor-status', createCursorStatusHandler()); router.post('/install-claude', createInstallClaudeHandler()); router.post('/auth-claude', createAuthClaudeHandler()); router.post('/store-api-key', createStoreApiKeyHandler()); diff --git a/apps/server/src/routes/setup/routes/cursor-status.ts b/apps/server/src/routes/setup/routes/cursor-status.ts new file mode 100644 index 000000000..65bd3aeaa --- /dev/null +++ b/apps/server/src/routes/setup/routes/cursor-status.ts @@ -0,0 +1,22 @@ +/** + * GET /cursor-status endpoint - Get Cursor CLI status + */ + +import type { Request, Response } from 'express'; +import { getCursorStatus } from '../get-cursor-status.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createCursorStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = await getCursorStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, 'Get Cursor status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts new file mode 100644 index 000000000..d90dcd6fb --- /dev/null +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -0,0 +1,573 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CursorProvider } from '@/providers/cursor-provider.js'; +import { spawn, exec } from 'child_process'; +import { EventEmitter } from 'events'; +import { Readable } from 'stream'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + exec: vi.fn(), +})); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + access: vi.fn().mockRejectedValue(new Error('ENOENT')), +})); + +// Mock os - needs to handle default import properly +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + const mockHomedir = () => '/home/testuser'; + return { + ...actual, + homedir: mockHomedir, + default: { + ...actual, + homedir: mockHomedir, + }, + }; +}); + +describe('cursor-provider.ts', () => { + let provider: CursorProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new CursorProvider(); + delete process.env.CURSOR_API_KEY; + // Default mock for exec (findCliPath) - assume cursor-agent is in PATH + vi.mocked(exec).mockImplementation((cmd: any, callback: any) => { + if (typeof callback === 'function') { + callback(null, { stdout: '/usr/local/bin/cursor-agent', stderr: '' }); + } + return {} as any; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getName', () => { + it("should return 'cursor' as provider name", () => { + expect(provider.getName()).toBe('cursor'); + }); + }); + + describe('executeQuery', () => { + function createMockProcess(messages: string[]) { + const mockStdout = new Readable({ + read() { + for (const msg of messages) { + this.push(msg + '\n'); + } + this.push(null); + }, + }); + + const mockStderr = new Readable({ + read() { + this.push(null); + }, + }); + + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = mockStdout; + mockProcess.stderr = mockStderr; + mockProcess.kill = vi.fn(); + + // Emit close event after streams end + setTimeout(() => { + mockProcess.emit('close', 0); + }, 10); + + return mockProcess; + } + + it('should execute simple text query and stream response', async () => { + const mockMessages = [ + JSON.stringify({ type: 'system', subtype: 'init', session_id: 'test-session' }), + JSON.stringify({ + type: 'assistant', + session_id: 'test-session', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello!' }] }, + }), + JSON.stringify({ + type: 'result', + subtype: 'success', + result: 'Hello!', + session_id: 'test-session', + }), + ]; + + const mockProcess = createMockProcess(mockMessages); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Hello', + model: 'cursor-sonnet', + cwd: '/test', + }); + + const results: any[] = []; + for await (const msg of generator) { + results.push(msg); + } + + // Should have assistant and result messages (system is filtered out) + expect(results.length).toBeGreaterThanOrEqual(2); + expect(results.some((r) => r.type === 'assistant')).toBe(true); + expect(results.some((r) => r.type === 'result')).toBe(true); + }); + + it('should pass correct CLI arguments', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Test prompt', + model: 'cursor-sonnet-4.5', + cwd: '/test/dir', + }); + + // Consume the generator + for await (const _ of generator) { + // Just consume + } + + // Model should be mapped from 'cursor-sonnet-4.5' to 'sonnet-4.5' + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining([ + '--print', + '--output-format', + 'stream-json', + '--stream-partial-output', + '--workspace', + '/test/dir', + '--model', + 'sonnet-4.5', + '--force', + 'Test prompt', + ]), + expect.objectContaining({ + cwd: '/test/dir', + }) + ); + }); + + it('should map cursor-sonnet alias to sonnet-4.5', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'cursor-sonnet', + cwd: '/test', + }); + + for await (const _ of generator) { + } + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining(['--model', 'sonnet-4.5']), + expect.any(Object) + ); + }); + + it('should map cursor-gpt5 alias to gpt-5.2', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'cursor-gpt5', + cwd: '/test', + }); + + for await (const _ of generator) { + } + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining(['--model', 'gpt-5.2']), + expect.any(Object) + ); + }); + + it('should map cursor-opus-thinking alias to opus-4.5-thinking', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'cursor-opus-thinking', + cwd: '/test', + }); + + for await (const _ of generator) { + } + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining(['--model', 'opus-4.5-thinking']), + expect.any(Object) + ); + }); + + it('should include --resume flag when sdkSessionId is provided', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Continue', + model: 'cursor-sonnet', + cwd: '/test', + sdkSessionId: 'existing-session-id', + }); + + for await (const _ of generator) { + // Consume + } + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining(['--resume', 'existing-session-id']), + expect.any(Object) + ); + }); + + it('should handle thinking messages', async () => { + const mockMessages = [ + JSON.stringify({ + type: 'thinking', + subtype: 'delta', + text: 'Let me think...', + session_id: 'test-session', + }), + JSON.stringify({ type: 'thinking', subtype: 'completed', session_id: 'test-session' }), + JSON.stringify({ + type: 'assistant', + session_id: 'test-session', + message: { role: 'assistant', content: [{ type: 'text', text: 'Done!' }] }, + }), + JSON.stringify({ + type: 'result', + subtype: 'success', + result: 'Done!', + session_id: 'test-session', + }), + ]; + + const mockProcess = createMockProcess(mockMessages); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = provider.executeQuery({ + prompt: 'Think about this', + model: 'cursor-opus-thinking', + cwd: '/test', + }); + + const results: any[] = []; + for await (const msg of generator) { + results.push(msg); + } + + // Should have thinking message converted to assistant + const thinkingMsg = results.find( + (r) => r.type === 'assistant' && r.message?.content?.[0]?.type === 'thinking' + ); + expect(thinkingMsg).toBeDefined(); + expect(thinkingMsg.message.content[0].thinking).toBe('Let me think...'); + }); + + it('should register abort signal listener on process', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + mockProcess.kill = vi.fn(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const abortController = new AbortController(); + + // Verify the abort controller signal has no listeners initially + expect(abortController.signal.aborted).toBe(false); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'cursor-sonnet', + cwd: '/test', + abortController, + }); + + // Consume the generator + for await (const _ of generator) { + // Just consume + } + + // The abort signal listener should have been added (we can't easily test this without + // actually aborting, but we verify that the code path ran without error) + expect(spawn).toHaveBeenCalled(); + }); + + it('should pass CURSOR_API_KEY from config', async () => { + const providerWithApiKey = new CursorProvider({ apiKey: 'test-api-key' }); + + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const generator = providerWithApiKey.executeQuery({ + prompt: 'Test', + model: 'cursor-sonnet', + cwd: '/test', + }); + + for await (const _ of generator) { + // Consume + } + + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ + CURSOR_API_KEY: 'test-api-key', + }), + }) + ); + }); + + it('should handle array prompt by extracting text', async () => { + const mockProcess = createMockProcess([ + JSON.stringify({ type: 'result', subtype: 'success', result: 'test', session_id: 'test' }), + ]); + vi.mocked(spawn).mockReturnValue(mockProcess); + + const arrayPrompt = [ + { type: 'text', text: 'Part 1' }, + { type: 'image', source: { type: 'base64', data: '...' } }, + { type: 'text', text: 'Part 2' }, + ]; + + const generator = provider.executeQuery({ + prompt: arrayPrompt as any, + model: 'cursor-sonnet', + cwd: '/test', + }); + + for await (const _ of generator) { + // Consume + } + + // Should have extracted and joined text parts + expect(spawn).toHaveBeenCalledWith( + '/usr/local/bin/cursor-agent', + expect.arrayContaining(['Part 1\nPart 2']), + expect.any(Object) + ); + }); + }); + + describe('detectInstallation', () => { + it('should detect installed cursor-agent', async () => { + // Mock exec for findCliPath (which command) + vi.mocked(exec).mockImplementation((cmd: any, callback: any) => { + if (typeof callback === 'function') { + callback(null, { stdout: '/usr/local/bin/cursor-agent', stderr: '' }); + } + return {} as any; + }); + // Mock spawn for version and status commands + const mockVersionProcess = new EventEmitter() as any; + mockVersionProcess.stdout = new Readable({ + read() { + this.push('1.0.0'); + this.push(null); + }, + }); + mockVersionProcess.stderr = new Readable({ + read() { + this.push(null); + }, + }); + const mockStatusProcess = new EventEmitter() as any; + mockStatusProcess.stdout = new Readable({ + read() { + this.push('Logged in as user@example.com'); + this.push(null); + }, + }); + mockStatusProcess.stderr = new Readable({ + read() { + this.push(null); + }, + }); + let callCount = 0; + vi.mocked(spawn).mockImplementation(() => { + callCount++; + const proc = callCount === 1 ? mockVersionProcess : mockStatusProcess; + setTimeout(() => proc.emit('close', 0), 10); + return proc; + }); + const result = await provider.detectInstallation(); + expect(result.installed).toBe(true); + expect(result.method).toBe('cli'); + expect(result.version).toBe('1.0.0'); + expect(result.path).toBe('/usr/local/bin/cursor-agent'); + }); + + it('should return not installed when cursor-agent not found', async () => { + // Mock exec to fail (not in PATH) + vi.mocked(exec).mockImplementation((cmd: any, callback: any) => { + if (typeof callback === 'function') { + callback(new Error('command not found'), { stdout: '', stderr: 'command not found' }); + } + return {} as any; + }); + const result = await provider.detectInstallation(); + expect(result.installed).toBe(false); + expect(result.method).toBe('cli'); + }); + + it('should detect CURSOR_API_KEY environment variable', async () => { + process.env.CURSOR_API_KEY = 'test-key'; + // Mock exec for findCliPath + vi.mocked(exec).mockImplementation((cmd: any, callback: any) => { + if (typeof callback === 'function') { + callback(null, { stdout: '/usr/local/bin/cursor-agent', stderr: '' }); + } + return {} as any; + }); + vi.mocked(spawn).mockImplementation(() => { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new Readable({ + read() { + this.push('1.0.0'); + this.push(null); + }, + }); + mockProcess.stderr = new Readable({ + read() { + this.push(null); + }, + }); + setTimeout(() => mockProcess.emit('close', 0), 10); + return mockProcess; + }); + const result = await provider.detectInstallation(); + expect(result.hasApiKey).toBe(true); + expect(result.authenticated).toBe(true); + }); + }); + + describe('getAvailableModels', () => { + it('should return 4 Cursor models', () => { + const models = provider.getAvailableModels(); + + expect(models).toHaveLength(4); + }); + + it('should include Cursor Opus 4.5 Thinking', () => { + const models = provider.getAvailableModels(); + + const opus = models.find((m) => m.id === 'cursor-opus-4.5-thinking'); + expect(opus).toBeDefined(); + expect(opus?.name).toBe('Cursor Opus 4.5 Thinking'); + expect(opus?.provider).toBe('cursor'); + }); + + it('should include Cursor Sonnet 4.5', () => { + const models = provider.getAvailableModels(); + + const sonnet = models.find((m) => m.id === 'cursor-sonnet-4.5'); + expect(sonnet).toBeDefined(); + expect(sonnet?.name).toBe('Cursor Sonnet 4.5'); + }); + + it('should include Cursor GPT-5.2', () => { + const models = provider.getAvailableModels(); + + const gpt5 = models.find((m) => m.id === 'cursor-gpt-5.2'); + expect(gpt5).toBeDefined(); + }); + + it('should mark Cursor Opus 4.5 Thinking as default', () => { + const models = provider.getAvailableModels(); + + const opus = models.find((m) => m.id === 'cursor-opus-4.5-thinking'); + expect(opus?.default).toBe(true); + }); + + it('should all support vision and tools', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.supportsVision).toBe(true); + expect(model.supportsTools).toBe(true); + }); + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it("should support 'vision' feature", () => { + expect(provider.supportsFeature('vision')).toBe(true); + }); + + it("should not support 'thinking' feature (CLI doesn't expose thinking control)", () => { + expect(provider.supportsFeature('thinking')).toBe(false); + }); + + it('should not support unknown features', () => { + expect(provider.supportsFeature('unknown')).toBe(false); + }); + }); + + describe('validateConfig', () => { + it('should validate config from base class', () => { + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('config management', () => { + it('should get and set config', () => { + provider.setConfig({ apiKey: 'test-key' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('test-key'); + }); + + it('should merge config updates', () => { + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ cliPath: '/custom/path' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('key1'); + expect(config.cliPath).toBe('/custom/path'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index 069fbf860..de37e6bf6 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ProviderFactory } from '@/providers/provider-factory.js'; import { ClaudeProvider } from '@/providers/claude-provider.js'; +import { CursorProvider } from '@/providers/cursor-provider.js'; describe('provider-factory.ts', () => { let consoleSpy: any; @@ -65,6 +66,28 @@ describe('provider-factory.ts', () => { }); }); + describe('Cursor models (cursor-* prefix)', () => { + it('should return CursorProvider for cursor-sonnet-4', () => { + const provider = ProviderFactory.getProviderForModel('cursor-sonnet-4'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for cursor-sonnet-4-thinking', () => { + const provider = ProviderFactory.getProviderForModel('cursor-sonnet-4-thinking'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for cursor-gpt-5', () => { + const provider = ProviderFactory.getProviderForModel('cursor-gpt-5'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should be case-insensitive for cursor models', () => { + const provider = ProviderFactory.getProviderForModel('CURSOR-SONNET-4'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + }); + describe('Unknown models', () => { it('should default to ClaudeProvider for unknown model', () => { const provider = ProviderFactory.getProviderForModel('unknown-model-123'); @@ -114,9 +137,15 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 1 provider', () => { + it('should include CursorProvider', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(1); + const hasCursorProvider = providers.some((p) => p instanceof CursorProvider); + expect(hasCursorProvider).toBe(true); + }); + + it('should return exactly 2 providers', () => { + const providers = ProviderFactory.getAllProviders(); + expect(providers).toHaveLength(2); }); it('should create new instances each time', () => { @@ -132,12 +161,14 @@ describe('provider-factory.ts', () => { const statuses = await ProviderFactory.checkAllProviders(); expect(statuses).toHaveProperty('claude'); + expect(statuses).toHaveProperty('cursor'); }); it('should call detectInstallation on each provider', async () => { const statuses = await ProviderFactory.checkAllProviders(); expect(statuses.claude).toHaveProperty('installed'); + expect(statuses.cursor).toHaveProperty('installed'); }); it('should return correct provider names as keys', async () => { @@ -145,7 +176,8 @@ describe('provider-factory.ts', () => { const keys = Object.keys(statuses); expect(keys).toContain('claude'); - expect(keys).toHaveLength(1); + expect(keys).toContain('cursor'); + expect(keys).toHaveLength(2); }); }); @@ -160,12 +192,19 @@ describe('provider-factory.ts', () => { expect(provider).toBeInstanceOf(ClaudeProvider); }); + it("should return CursorProvider for 'cursor'", () => { + const provider = ProviderFactory.getProviderByName('cursor'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + it('should be case-insensitive', () => { const provider1 = ProviderFactory.getProviderByName('CLAUDE'); const provider2 = ProviderFactory.getProviderByName('ANTHROPIC'); + const provider3 = ProviderFactory.getProviderByName('CURSOR'); expect(provider1).toBeInstanceOf(ClaudeProvider); expect(provider2).toBeInstanceOf(ClaudeProvider); + expect(provider3).toBeInstanceOf(CursorProvider); }); it('should return null for unknown provider', () => { @@ -218,5 +257,14 @@ describe('provider-factory.ts', () => { expect(hasClaudeModels).toBe(true); }); + + it('should include Cursor models', () => { + const models = ProviderFactory.getAllAvailableModels(); + + // Cursor models should include cursor-* in their IDs + const hasCursorModels = models.some((m) => m.id.toLowerCase().includes('cursor')); + + expect(hasCursorModels).toBe(true); + }); }); }); diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 950d77de5..9f8f6b5f5 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -50,7 +50,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants'; +import { ALL_MODELS } from '@/components/views/board-view/shared/model-constants'; export function AgentView() { const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); @@ -542,6 +542,45 @@ export function AgentView() { {/* Status indicators & actions */}
+ {/* Model Selector */} + + + + + + {ALL_MODELS.map((model) => ( + setSelectedModel(model.id)} + className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} + data-testid={`model-option-${model.id}`} + > +
+
+ {model.label} + + {model.provider} + +
+ {model.description} +
+
+ ))} +
+
+ {currentTool && (
@@ -921,25 +960,29 @@ export function AgentView() { - - {CLAUDE_MODELS.map((model) => ( + + {ALL_MODELS.map((model) => ( setSelectedModel(model.id)} className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')} - data-testid={`model-option-${model.id}`} + data-testid={`model-option-input-${model.id}`} >
- {model.label} +
+ {model.label} + + {model.provider} + +
{model.description}
diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index a5eea2c5e..a7aaa7614 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Dialog, DialogContent, @@ -32,6 +32,8 @@ import { PlanningMode, Feature, } from '@/store/app-store'; +import type { ModelProvider } from '@automaker/types'; +import { PROVIDERS } from '../shared/provider-constants'; import { ModelSelector, ThinkingLevelSelector, @@ -41,6 +43,7 @@ import { BranchSelector, PlanningModeSelector, AncestorContextSection, + getModelForProvider, } from '../shared'; import { DropdownMenu, @@ -120,6 +123,15 @@ export function AddFeatureDialog({ branchName: '', priority: 2 as number, // Default to medium priority }); + const [selectedProvider, setSelectedProvider] = useState('claude'); + const [providerModelMemory, setProviderModelMemory] = useState>( + { + claude: 'opus', + cursor: 'cursor-sonnet', + } + ); + // Track if provider was explicitly changed by user to avoid resetting on dialog reopen + const userChangedProviderRef = useRef(false); const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState( () => new Map() ); @@ -153,14 +165,24 @@ export function AddFeatureDialog({ ? aiProfiles.find((p) => p.id === defaultAIProfileId) : null; + const initialModel = defaultProfile?.model ?? 'opus'; + setNewFeature((prev) => ({ ...prev, skipTests: defaultSkipTests, branchName: defaultBranch || '', // Use default profile's model/thinkingLevel if set, else fallback to defaults - model: defaultProfile?.model ?? 'opus', + model: initialModel, thinkingLevel: defaultProfile?.thinkingLevel ?? 'none', })); + + // Only initialize provider if user hasn't explicitly changed it + if (!userChangedProviderRef.current) { + const initialProvider = + PROVIDERS.find((p) => p.models.some((m) => m.id === initialModel))?.id || 'claude'; + setSelectedProvider(initialProvider); + } + setUseCurrentBranch(true); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); @@ -318,14 +340,51 @@ export function AddFeatureDialog({ model, thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none', }); + + // Remember this model for its provider + const modelProvider = PROVIDERS.find((p) => p.models.some((m) => m.id === model))?.id; + + if (modelProvider) { + setProviderModelMemory({ + ...providerModelMemory, + [modelProvider]: model, + }); + } + }; + + const handleProviderSelect = (provider: ModelProvider) => { + setSelectedProvider(provider); + // Mark that user explicitly changed provider + userChangedProviderRef.current = true; + + // Restore previously selected model for this provider + const rememberedModel = providerModelMemory[provider]; + if (rememberedModel) { + handleModelSelect(rememberedModel); + } else { + // Fallback: select first model of provider + const providerConfig = PROVIDERS.find((p) => p.id === provider); + if (providerConfig && providerConfig.models.length > 0) { + handleModelSelect(providerConfig.models[0].id); + } + } }; const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { + // Map the profile's model to the equivalent model for the current provider + const mappedModel = getModelForProvider(model, selectedProvider); + setNewFeature({ ...newFeature, - model, + model: mappedModel, thinkingLevel, }); + + // Update the model memory for the current provider (keep user's provider selection) + setProviderModelMemory({ + ...providerModelMemory, + [selectedProvider]: mappedModel, + }); }; const newModelAllowsThinking = modelSupportsThinking(newFeature.model); @@ -517,7 +576,7 @@ export function AddFeatureDialog({ {/* Quick Select Profile Section */} p.provider === selectedProvider)} selectedModel={newFeature.model} selectedThinkingLevel={newFeature.thinkingLevel} onSelect={handleProfileSelect} @@ -533,10 +592,15 @@ export function AddFeatureDialog({
)} - {/* Claude Models Section */} + {/* Provider and Model Selection */} {(!showProfilesOnly || showAdvancedOptions) && ( <> - + {newModelAllowsThinking && ( (() => { + const initialModel = (feature?.model ?? 'opus') as AgentModel; + return PROVIDERS.find((p) => p.models.some((m) => m.id === initialModel))?.id || 'claude'; + }); + const [providerModelMemory, setProviderModelMemory] = useState>( + { + claude: 'opus', + cursor: 'cursor-sonnet', + } + ); // Get enhancement model and worktrees setting from store const { enhancementModel, useWorktrees } = useAppStore(); @@ -127,6 +140,12 @@ export function EditFeatureDialog({ setRequirePlanApproval(feature.requirePlanApproval ?? false); // If feature has no branchName, default to using current branch setUseCurrentBranch(!feature.branchName); + + // Initialize provider based on feature's model + const featureModel = (feature.model ?? 'opus') as AgentModel; + const featureProvider = + PROVIDERS.find((p) => p.models.some((m) => m.id === featureModel))?.id || 'claude'; + setSelectedProvider(featureProvider); } else { setEditFeaturePreviewMap(new Map()); setShowEditAdvancedOptions(false); @@ -194,15 +213,51 @@ export function EditFeatureDialog({ model, thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none', }); + + // Remember this model for its provider + const modelProvider = PROVIDERS.find((p) => p.models.some((m) => m.id === model))?.id; + + if (modelProvider) { + setProviderModelMemory({ + ...providerModelMemory, + [modelProvider]: model, + }); + } + }; + + const handleProviderSelect = (provider: ModelProvider) => { + setSelectedProvider(provider); + + // Restore previously selected model for this provider + const rememberedModel = providerModelMemory[provider]; + if (rememberedModel) { + handleModelSelect(rememberedModel); + } else { + // Fallback: select first model of provider + const providerConfig = PROVIDERS.find((p) => p.id === provider); + if (providerConfig && providerConfig.models.length > 0) { + handleModelSelect(providerConfig.models[0].id); + } + } }; const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { if (!editingFeature) return; + + // Map the profile's model to the equivalent model for the current provider + const mappedModel = getModelForProvider(model, selectedProvider); + setEditingFeature({ ...editingFeature, - model, + model: mappedModel, thinkingLevel, }); + + // Update the model memory for the current provider (keep user's provider selection) + setProviderModelMemory({ + ...providerModelMemory, + [selectedProvider]: mappedModel, + }); }; const handleEnhanceDescription = async () => { @@ -434,7 +489,7 @@ export function EditFeatureDialog({ {/* Quick Select Profile Section */} p.provider === selectedProvider)} selectedModel={editingFeature.model ?? 'opus'} selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'} onSelect={handleProfileSelect} @@ -452,6 +507,8 @@ export function EditFeatureDialog({ {editModelAllowsThinking && ( diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index d08a9d21d..a8b84389b 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -1,4 +1,4 @@ -import type { AgentModel, ThinkingLevel } from '@/store/app-store'; +import type { AgentModel, ThinkingLevel, ModelProvider } from '@automaker/types'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; export type ModelOption = { @@ -6,7 +6,8 @@ export type ModelOption = { label: string; description: string; badge?: string; - provider: 'claude'; + provider: ModelProvider; + default?: boolean; }; export const CLAUDE_MODELS: ModelOption[] = [ @@ -33,6 +34,78 @@ export const CLAUDE_MODELS: ModelOption[] = [ }, ]; +export const CURSOR_MODELS: ModelOption[] = [ + { + id: 'cursor-opus-thinking', + label: 'Cursor Opus 4.5 Thinking', + description: 'Claude Opus 4.5 with extended thinking via Cursor.', + badge: 'Premium', + provider: 'cursor', + }, + { + id: 'cursor-sonnet', + label: 'Cursor Sonnet 4.5', + description: 'Claude Sonnet 4.5 via Cursor subscription.', + badge: 'Balanced', + provider: 'cursor', + default: true, + }, + { + id: 'cursor-gpt5', + label: 'Cursor GPT-5.2', + description: 'OpenAI GPT-5.2 via Cursor subscription.', + badge: 'Premium', + provider: 'cursor', + }, + { + id: 'cursor-composer', + label: 'Cursor Composer', + description: 'Cursor Composer model.', + badge: 'Fast', + provider: 'cursor', + }, +]; + +export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS]; + +/** + * Maps profile models (Claude-based) to equivalent models for each provider. + * When a profile is selected, we use this to get the appropriate model for the current provider. + */ +export const PROFILE_MODEL_MAP: Record> = { + claude: { + opus: 'opus', + sonnet: 'sonnet', + haiku: 'haiku', + }, + cursor: { + opus: 'cursor-opus-thinking', + sonnet: 'cursor-sonnet', + haiku: 'cursor-composer', + }, +}; + +/** + * Get the equivalent model for a provider based on a profile's base model. + * Falls back to the original model if no mapping exists. + */ +export function getModelForProvider(profileModel: AgentModel, provider: ModelProvider): AgentModel { + const mapping = PROFILE_MODEL_MAP[provider]; + if (mapping && mapping[profileModel]) { + return mapping[profileModel]; + } + // If it's already a cursor model and provider is cursor, keep it + if (provider === 'cursor' && profileModel.startsWith('cursor-')) { + return profileModel; + } + // If it's a claude model and provider is claude, keep it + if (provider === 'claude' && !profileModel.startsWith('cursor-')) { + return profileModel; + } + // Fallback to the original model + return profileModel; +} + export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; export const THINKING_LEVEL_LABELS: Record = { diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 55a0fe83b..5935c3c0b 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -1,35 +1,75 @@ import { Label } from '@/components/ui/label'; -import { Brain } from 'lucide-react'; +import { Brain, MousePointer2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { AgentModel } from '@/store/app-store'; -import { CLAUDE_MODELS, ModelOption } from './model-constants'; +import type { ModelProvider } from '@automaker/types'; +import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; +import { PROVIDERS } from './provider-constants'; +import { ProviderSelector } from './provider-selector'; interface ModelSelectorProps { selectedModel: AgentModel; onModelSelect: (model: AgentModel) => void; + selectedProvider: ModelProvider; + onProviderSelect: (provider: ModelProvider) => void; testIdPrefix?: string; + showCursor?: boolean; } -export function ModelSelector({ +function ModelGroup({ + label, + icon: Icon, + badge, + badgeColor = 'primary', + models, selectedModel, onModelSelect, - testIdPrefix = 'model-select', -}: ModelSelectorProps) { + testIdPrefix, +}: { + label: string; + icon: React.ComponentType<{ className?: string }>; + badge: string; + badgeColor?: 'primary' | 'cyan'; + models: ModelOption[]; + selectedModel: AgentModel; + onModelSelect: (model: AgentModel) => void; + testIdPrefix: string; +}) { + const colorClasses = { + primary: { + icon: 'text-primary', + badge: 'border-primary/40 text-primary', + selected: 'bg-primary text-primary-foreground border-primary', + }, + cyan: { + icon: 'text-cyan-500', + badge: 'border-cyan-500/40 text-cyan-500', + selected: 'bg-cyan-600 text-white border-cyan-600', + }, + }; + + const colors = colorClasses[badgeColor]; + return (
- - Native + + {badge}
- {CLAUDE_MODELS.map((option) => { + {models.map((option) => { const isSelected = selectedModel === option.id; - const shortName = option.label.replace('Claude ', ''); + // Extract short name from label + const shortName = option.label + .replace('Claude ', '') + .replace('Cursor ', '') + .replace(' 4', '') + .replace(' Thinking', ' Think'); return (
); } + +export function ModelSelector({ + selectedModel, + onModelSelect, + selectedProvider, + onProviderSelect, + testIdPrefix = 'model-select', + showCursor = true, +}: ModelSelectorProps) { + const availableProviders = showCursor ? PROVIDERS : PROVIDERS.filter((p) => p.id === 'claude'); + const currentProvider = availableProviders.find((p) => p.id === selectedProvider); + const availableModels = currentProvider?.models || []; + + const getProviderIcon = (providerId: ModelProvider) => { + return providerId === 'claude' ? Brain : MousePointer2; + }; + + const Icon = currentProvider ? getProviderIcon(currentProvider.id) : Brain; + + return ( +
+ + + {currentProvider && ( + + )} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/shared/provider-constants.ts b/apps/ui/src/components/views/board-view/shared/provider-constants.ts new file mode 100644 index 000000000..fd9cc9d95 --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/provider-constants.ts @@ -0,0 +1,30 @@ +import type { ModelProvider } from '@automaker/types'; +import { CLAUDE_MODELS, CURSOR_MODELS, type ModelOption } from './model-constants'; + +export interface ProviderConfig { + id: ModelProvider; + name: string; + badge: string; + badgeColor: 'primary' | 'cyan'; + models: ModelOption[]; + description?: string; +} + +export const PROVIDERS: ProviderConfig[] = [ + { + id: 'claude', + name: 'Claude (SDK)', + badge: 'Native', + badgeColor: 'primary', + models: CLAUDE_MODELS, + description: 'Native SDK integration with Claude models', + }, + { + id: 'cursor', + name: 'Cursor CLI', + badge: 'CLI', + badgeColor: 'cyan', + models: CURSOR_MODELS, + description: 'CLI-based Cursor CLI integration', + }, +]; diff --git a/apps/ui/src/components/views/board-view/shared/provider-selector.tsx b/apps/ui/src/components/views/board-view/shared/provider-selector.tsx new file mode 100644 index 000000000..593bd16f2 --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/provider-selector.tsx @@ -0,0 +1,51 @@ +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { ModelProvider } from '@automaker/types'; +import { PROVIDERS, type ProviderConfig } from './provider-constants'; + +interface ProviderSelectorProps { + selectedProvider: ModelProvider; + onProviderSelect: (provider: ModelProvider) => void; + availableProviders?: ProviderConfig[]; + testIdPrefix?: string; +} + +export function ProviderSelector({ + selectedProvider, + onProviderSelect, + availableProviders = PROVIDERS, + testIdPrefix = 'provider-select', +}: ProviderSelectorProps) { + const selectedProviderConfig = availableProviders.find((p) => p.id === selectedProvider); + + return ( +
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/profiles-view.tsx b/apps/ui/src/components/views/profiles-view.tsx index e11ec8b63..0d0e8433d 100644 --- a/apps/ui/src/components/views/profiles-view.tsx +++ b/apps/ui/src/components/views/profiles-view.tsx @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; @@ -25,6 +26,8 @@ import { } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components'; +import type { ModelProvider } from '@automaker/types'; +import { PROVIDERS } from './board-view/shared/provider-constants'; export function ProfilesView() { const { @@ -40,6 +43,7 @@ export function ProfilesView() { const [showAddDialog, setShowAddDialog] = useState(false); const [editingProfile, setEditingProfile] = useState(null); const [profileToDelete, setProfileToDelete] = useState(null); + const [selectedProvider, setSelectedProvider] = useState('claude'); // Sensors for drag-and-drop const sensors = useSensors( @@ -50,9 +54,15 @@ export function ProfilesView() { }) ); - // Separate built-in and custom profiles - const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]); - const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]); + // Separate built-in and custom profiles, filtered by selected provider + const builtInProfiles = useMemo( + () => aiProfiles.filter((p) => p.isBuiltIn && p.provider === selectedProvider), + [aiProfiles, selectedProvider] + ); + const customProfiles = useMemo( + () => aiProfiles.filter((p) => !p.isBuiltIn && p.provider === selectedProvider), + [aiProfiles, selectedProvider] + ); const handleDragEnd = useCallback( (event: DragEndEvent) => { @@ -134,6 +144,24 @@ export function ProfilesView() { {/* Content */}
+ {/* Provider Tabs */} + setSelectedProvider(value as ModelProvider)} + > + + {PROVIDERS.map((provider) => ( + + {provider.name} + + ))} + + + {/* Custom Profiles Section */}
diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index b888c9b6c..b44dea1b9 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -9,7 +9,7 @@ import { DeleteProjectDialog } from './settings-view/components/delete-project-d import { SettingsNavigation } from './settings-view/components/settings-navigation'; import { ApiKeysSection } from './settings-view/api-keys/api-keys-section'; import { ClaudeUsageSection } from './settings-view/api-keys/claude-usage-section'; -import { ClaudeCliStatus } from './settings-view/cli-status/claude-cli-status'; +import { ClaudeCliStatus, CursorCliStatusCard } from './settings-view/cli-status'; import { ClaudeMdSettings } from './settings-view/claude/claude-md-settings'; import { AIEnhancementSection } from './settings-view/ai-enhancement'; import { AppearanceSection } from './settings-view/appearance/appearance-section'; @@ -86,7 +86,14 @@ export function SettingsView() { }; // Use CLI status hook - const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } = useCliStatus(); + const { + claudeCliStatus, + cursorCliStatus, + isCheckingClaudeCli, + isCheckingCursorCli, + handleRefreshClaudeCli, + handleRefreshCursorCli, + } = useCliStatus(); // Use settings view navigation hook const { activeView, navigateTo } = useSettingsView(); @@ -97,7 +104,7 @@ export function SettingsView() { // Render the active section based on current view const renderActiveSection = () => { switch (activeView) { - case 'claude': + case 'providers': return (
+ void; +} + +export function CursorCliStatusCard({ status, isChecking, onRefresh }: CursorCliStatusProps) { + if (!status) return null; + + return ( +
+
+
+
+
+ +
+

Cursor CLI

+
+ +
+

+ Cursor CLI enables using your Cursor subscription for AI tasks. +

+
+
+ {status.success && status.status === 'installed' ? ( +
+
+
+ +
+
+

Cursor CLI Installed

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version: {status.version} +

+ )} + {status.path && ( +

+ Path: {status.path} +

+ )} + {status.auth && ( +

+ Auth:{' '} + + {status.auth.authenticated + ? `✓ ${status.auth.method}` + : 'Not authenticated'} + +

+ )} +
+
+
+
+ ) : ( +
+
+
+ +
+
+

Cursor CLI Not Detected

+

+ Install cursor-agent to use your Cursor subscription for AI tasks. +

+
+
+
+

Installation:

+
+
+

+ Install via curl +

+ + curl https://cursor.com/install -fsS | bash + +
+
+
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/cli-status/index.ts b/apps/ui/src/components/views/settings-view/cli-status/index.ts index a6d7cf876..a3515c754 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/index.ts +++ b/apps/ui/src/components/views/settings-view/cli-status/index.ts @@ -1 +1,2 @@ export { ClaudeCliStatus } from './claude-cli-status'; +export { CursorCliStatusCard } from './cursor-cli-status'; diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index e32c2223a..661c19d33 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -21,7 +21,7 @@ export interface NavigationItem { // Navigation items for the settings side panel export const NAV_ITEMS: NavigationItem[] = [ { id: 'api-keys', label: 'API Keys', icon: Key }, - { id: 'claude', label: 'Claude', icon: Terminal }, + { id: 'providers', label: 'AI Providers', icon: Terminal }, { id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts index dff1dfa92..6fb59a489 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-cli-status.ts @@ -18,16 +18,32 @@ interface CliStatusResult { error?: string; } +interface CursorCliStatusResult { + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + error?: string; +} + /** - * Custom hook for managing Claude CLI status + * Custom hook for managing CLI status for multiple providers * Handles checking CLI installation, authentication, and refresh functionality */ export function useCliStatus() { const { setClaudeAuthStatus } = useSetupStore(); const [claudeCliStatus, setClaudeCliStatus] = useState(null); + const [cursorCliStatus, setCursorCliStatus] = useState(null); const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false); + const [isCheckingCursorCli, setIsCheckingCursorCli] = useState(false); // Check CLI status on mount useEffect(() => { @@ -44,6 +60,16 @@ export function useCliStatus() { } } + // Check Cursor CLI + if (api?.setup?.getCursorStatus) { + try { + const status = await api.setup.getCursorStatus(); + setCursorCliStatus(status); + } catch (error) { + console.error('Failed to check Cursor CLI status:', error); + } + } + // Check Claude auth status (re-fetch on mount to ensure persistence) if (api?.setup?.getClaudeStatus) { try { @@ -108,9 +134,28 @@ export function useCliStatus() { } }, []); + // Refresh Cursor CLI status + const handleRefreshCursorCli = useCallback(async () => { + setIsCheckingCursorCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getCursorStatus) { + const status = await api.setup.getCursorStatus(); + setCursorCliStatus(status); + } + } catch (error) { + console.error('Failed to refresh Cursor CLI status:', error); + } finally { + setIsCheckingCursorCli(false); + } + }, []); + return { claudeCliStatus, + cursorCliStatus, isCheckingClaudeCli, + isCheckingCursorCli, handleRefreshClaudeCli, + handleRefreshCursorCli, }; } diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 2e3f784f9..aeb7cf31f 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; export type SettingsViewId = | 'api-keys' - | 'claude' + | 'providers' | 'ai-enhancement' | 'appearance' | 'terminal' diff --git a/apps/ui/src/components/views/settings-view/shared/types.ts b/apps/ui/src/components/views/settings-view/shared/types.ts index 0795829f1..0b8c2afe4 100644 --- a/apps/ui/src/components/views/settings-view/shared/types.ts +++ b/apps/ui/src/components/views/settings-view/shared/types.ts @@ -19,6 +19,20 @@ export interface CliStatus { error?: string; } +export interface CursorCliStatus { + success: boolean; + status?: string; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + error?: string; +} + export type KanbanDetailLevel = 'minimal' | 'standard' | 'detailed'; export interface Project { diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 698f915ed..015371d94 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -551,6 +551,20 @@ export interface ElectronAPI { user: string | null; error?: string; }>; + getCursorStatus?: () => Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + error?: string; + }>; onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; }; @@ -1119,6 +1133,20 @@ interface SetupAPI { user: string | null; error?: string; }>; + getCursorStatus?: () => Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + error?: string; + }>; onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; } @@ -1217,6 +1245,20 @@ function createMockSetupAPI(): SetupAPI { }; }, + getCursorStatus: async () => { + console.log('[Mock] Getting Cursor CLI status'); + return { + success: true, + status: 'not_installed', + installed: false, + auth: { + authenticated: false, + method: 'none', + hasApiKey: false, + }, + }; + }, + onInstallProgress: (callback) => { // Mock progress events return () => {}; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 13def2c97..57788a2df 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -441,6 +441,21 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.get('/api/setup/claude-status'), + getCursorStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + error?: string; + }> => this.get('/api/setup/cursor-status'), + installClaude: (): Promise<{ success: boolean; message?: string; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 874e1a6df..3e097c43a 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -859,11 +859,11 @@ export interface AppActions { // Default built-in AI profiles const DEFAULT_AI_PROFILES: AIProfile[] = [ + // Claude profiles { id: 'profile-heavy-task', name: 'Heavy Task', - description: - 'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.', + description: 'Opus with Ultrathink for complex architecture, migrations, or deep debugging.', model: 'opus', thinkingLevel: 'ultrathink', provider: 'claude', @@ -873,7 +873,7 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: 'profile-balanced', name: 'Balanced', - description: 'Claude Sonnet with medium thinking for typical development tasks.', + description: 'Sonnet with medium thinking for typical development tasks.', model: 'sonnet', thinkingLevel: 'medium', provider: 'claude', @@ -883,13 +883,44 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [ { id: 'profile-quick-edit', name: 'Quick Edit', - description: 'Claude Haiku for fast, simple edits and minor fixes.', + description: 'Haiku for fast, simple edits and minor fixes.', model: 'haiku', thinkingLevel: 'none', provider: 'claude', isBuiltIn: true, icon: 'Zap', }, + // Cursor profiles + { + id: 'profile-cursor-heavy-task', + name: 'Heavy Task', + description: 'Opus with Ultrathink for complex architecture, migrations, or deep debugging.', + model: 'cursor-opus-thinking', + thinkingLevel: 'ultrathink', + provider: 'cursor', + isBuiltIn: true, + icon: 'Brain', + }, + { + id: 'profile-cursor-balanced', + name: 'Balanced', + description: 'Sonnet with medium thinking for typical development tasks.', + model: 'cursor-sonnet', + thinkingLevel: 'medium', + provider: 'cursor', + isBuiltIn: true, + icon: 'Scale', + }, + { + id: 'profile-cursor-quick-edit', + name: 'Quick Edit', + description: 'Composer for fast, simple edits and minor fixes.', + model: 'cursor-composer', + thinkingLevel: 'none', + provider: 'cursor', + isBuiltIn: true, + icon: 'Zap', + }, ]; const initialState: AppState = { diff --git a/apps/ui/tests/agent/agent-model-selection.spec.ts b/apps/ui/tests/agent/agent-model-selection.spec.ts new file mode 100644 index 000000000..489ee5b1e --- /dev/null +++ b/apps/ui/tests/agent/agent-model-selection.spec.ts @@ -0,0 +1,145 @@ +/** + * Agent Model Selection E2E Test + * + * Tests for model selector dropdown in Agent view + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + navigateToAgent, + clickModelSelector, + selectAgentModel, + getSelectedModelLabel, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('agent-model-test'); + +test.describe('Agent Model Selection', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'sessions'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for model selection testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should display model selector', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToAgent(page); + + // Verify model selector is visible + const modelSelector = page.locator('[data-testid="model-selector"]'); + await expect(modelSelector).toBeVisible(); + }); + + test('should show all models in dropdown', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToAgent(page); + + // Open model selector dropdown + await clickModelSelector(page); + + // Verify Claude models are visible + await expect(page.locator('[data-testid="model-option-haiku"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-option-sonnet"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-option-opus"]')).toBeVisible(); + + // Verify Cursor models are visible + await expect(page.locator('[data-testid="model-option-cursor-opus-thinking"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-option-cursor-sonnet"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-option-cursor-gpt5"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-option-cursor-composer"]')).toBeVisible(); + }); + + test('should select a Cursor model', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToAgent(page); + + // Select Cursor Sonnet model + await selectAgentModel(page, 'cursor-sonnet'); + + // Verify selection is reflected in the button + const selectedLabel = await getSelectedModelLabel(page); + expect(selectedLabel).toContain('Sonnet'); + }); + + test('should show provider badge for each model', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToAgent(page); + + // Open model selector dropdown + await clickModelSelector(page); + + // Check that Claude models show 'claude' provider + const claudeSonnetOption = page.locator('[data-testid="model-option-sonnet"]'); + await expect(claudeSonnetOption).toContainText('claude'); + + // Check that Cursor models show 'cursor' provider + const cursorSonnetOption = page.locator('[data-testid="model-option-cursor-sonnet"]'); + await expect(cursorSonnetOption).toContainText('cursor'); + }); + + test('should switch between Claude and Cursor models', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToAgent(page); + + // Default should be Sonnet (Claude) + let selectedLabel = await getSelectedModelLabel(page); + expect(selectedLabel).toContain('Sonnet'); + + // Switch to Cursor GPT-5 + await selectAgentModel(page, 'cursor-gpt5'); + selectedLabel = await getSelectedModelLabel(page); + expect(selectedLabel).toContain('GPT-5'); + + // Switch back to Claude Opus + await selectAgentModel(page, 'opus'); + selectedLabel = await getSelectedModelLabel(page); + expect(selectedLabel).toContain('Opus'); + }); +}); diff --git a/apps/ui/tests/features/add-feature-provider-selection.spec.ts b/apps/ui/tests/features/add-feature-provider-selection.spec.ts new file mode 100644 index 000000000..d7b82feab --- /dev/null +++ b/apps/ui/tests/features/add-feature-provider-selection.spec.ts @@ -0,0 +1,197 @@ +/** + * Provider Selection E2E Test + * + * Tests for provider dropdown and model selection in Add Feature dialog + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + clickAddFeature, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('provider-selection-test'); + +test.describe('Provider Selection in Add Feature Dialog', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for provider selection testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should display provider dropdown with available providers', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + const providerSelect = page.locator('[data-testid="provider-select"]'); + await expect(providerSelect).toBeVisible(); + + await providerSelect.click(); + await page.waitForTimeout(300); + + const claudeOption = page.getByRole('option', { name: /Claude \(SDK\)/ }); + await expect(claudeOption).toBeVisible(); + }); + + test('should select Claude provider and show Claude models', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Claude \(SDK\)/ }).click(); + await page.waitForTimeout(300); + + await expect(page.locator('[data-testid="model-select-opus"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-select-sonnet"]')).toBeVisible(); + await expect(page.locator('[data-testid="model-select-haiku"]')).toBeVisible(); + + await expect( + page.locator('[data-testid="model-select-cursor-opus-thinking"]') + ).not.toBeVisible(); + }); + + test('should switch from Claude to Cursor provider', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Claude \(SDK\)/ }).click(); + await page.waitForTimeout(300); + await page.click('[data-testid="model-select-opus"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Cursor CLI/ }).click(); + await page.waitForTimeout(300); + + await expect(page.locator('[data-testid="model-select-cursor-sonnet"]')).toBeVisible(); + + await expect(page.locator('[data-testid="model-select-opus"]')).not.toBeVisible(); + }); + + test('should remember model selection per provider', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Claude \(SDK\)/ }).click(); + await page.waitForTimeout(300); + await page.click('[data-testid="model-select-opus"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Cursor CLI/ }).click(); + await page.waitForTimeout(300); + await page.click('[data-testid="model-select-cursor-sonnet"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Claude \(SDK\)/ }).click(); + await page.waitForTimeout(300); + + const claudeOpusModel = page.locator('[data-testid="model-select-opus"]'); + await expect(claudeOpusModel).toHaveClass(/bg-primary/); + }); + + test('should persist provider selection when dialog is reopened', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + await page.getByRole('option', { name: /Cursor CLI/ }).click(); + await page.waitForTimeout(300); + await page.click('[data-testid="model-select-cursor-sonnet"]'); + + await page.keyboard.press('Escape'); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + const providerSelect = page.locator('[data-testid="provider-select"]'); + await expect(providerSelect).toContainText('Cursor CLI'); + }); + + test('should show only available providers in dropdown', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + await page.goto('/board'); + await waitForNetworkIdle(page); + + await clickAddFeature(page); + + await page.click('[data-testid="tab-model"]'); + + await page.locator('[data-testid="provider-select"]').click(); + await page.waitForTimeout(300); + + const claudeOption = page.getByRole('option', { name: /Claude \(SDK\)/ }); + const cursorOption = page.getByRole('option', { name: /Cursor CLI/ }); + + await expect(claudeOption).toBeVisible(); + await expect(cursorOption).toBeVisible(); + }); +}); diff --git a/apps/ui/tests/features/add-feature-to-backlog.spec.ts b/apps/ui/tests/features/add-feature-to-backlog.spec.ts index 2231e0be9..eec1d7ea9 100644 --- a/apps/ui/tests/features/add-feature-to-backlog.spec.ts +++ b/apps/ui/tests/features/add-feature-to-backlog.spec.ts @@ -70,6 +70,12 @@ test.describe('Feature Backlog', () => { }); await clickAddFeature(page); + + // Verify provider selector is present in Model tab + await page.click('[data-testid="tab-model"]'); + await expect(page.locator('[data-testid="provider-select"]')).toBeVisible(); + await page.click('[data-testid="tab-prompt"]'); + await fillAddFeatureDialog(page, featureDescription); await confirmAddFeature(page); diff --git a/apps/ui/tests/profiles/profiles-provider-selection.spec.ts b/apps/ui/tests/profiles/profiles-provider-selection.spec.ts new file mode 100644 index 000000000..2ee3f4e5a --- /dev/null +++ b/apps/ui/tests/profiles/profiles-provider-selection.spec.ts @@ -0,0 +1,143 @@ +/** + * Profiles Provider Selection E2E Test + * + * Tests for provider tabs and profile filtering in Profiles view + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockProjectWithProfiles, + waitForNetworkIdle, + navigateToProfiles, + clickNewProfileButton, + fillProfileForm, + saveProfile, + waitForSuccessToast, + countCustomProfiles, + selectProviderTab, + getActiveProviderTab, +} from '../utils'; + +test.describe('Profiles Provider Selection', () => { + test('should display provider tabs', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + // Verify provider tabs are visible + const providerTabs = page.locator('[data-testid="provider-tabs"]'); + await expect(providerTabs).toBeVisible(); + + // Verify both provider tabs exist + const claudeTab = page.locator('[data-testid="provider-tab-claude"]'); + const cursorTab = page.locator('[data-testid="provider-tab-cursor"]'); + + await expect(claudeTab).toBeVisible(); + await expect(cursorTab).toBeVisible(); + + // Verify tab labels + await expect(claudeTab).toContainText('Claude (SDK)'); + await expect(cursorTab).toContainText('Cursor CLI'); + }); + + test('should switch between provider tabs', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + // Default should be Claude + const claudeTab = page.locator('[data-testid="provider-tab-claude"]'); + await expect(claudeTab).toHaveAttribute('data-state', 'active'); + + // Switch to Cursor CLI + await selectProviderTab(page, 'cursor'); + + // Verify Cursor tab is now active + const cursorTab = page.locator('[data-testid="provider-tab-cursor"]'); + await expect(cursorTab).toHaveAttribute('data-state', 'active'); + await expect(claudeTab).toHaveAttribute('data-state', 'inactive'); + }); + + test('should filter built-in profiles by provider', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + // On Claude tab, should see Claude built-in profiles + const claudeProfiles = page.locator('[data-testid^="profile-card-"]'); + const claudeProfileCount = await claudeProfiles.count(); + expect(claudeProfileCount).toBeGreaterThan(0); + + // Switch to Cursor CLI + await selectProviderTab(page, 'cursor'); + await page.waitForTimeout(300); + + // Should see different profiles (Cursor built-in profiles) + const cursorProfiles = page.locator('[data-testid^="profile-card-"]'); + const cursorProfileCount = await cursorProfiles.count(); + + // Both providers should have built-in profiles + expect(cursorProfileCount).toBeGreaterThan(0); + }); + + test('should create profile and show under Claude tab', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + // Verify we're on Claude tab (default) + const claudeTab = page.locator('[data-testid="provider-tab-claude"]'); + await expect(claudeTab).toHaveAttribute('data-state', 'active'); + + // Create a new profile (profiles are created with Claude models) + await clickNewProfileButton(page); + + await fillProfileForm(page, { + name: 'Test Profile', + description: 'A test profile', + icon: 'Zap', + model: 'sonnet', + thinkingLevel: 'medium', + }); + + await saveProfile(page); + await waitForSuccessToast(page, 'Profile created'); + + // Verify profile appears under Claude tab + const claudeCustomCount = await countCustomProfiles(page); + expect(claudeCustomCount).toBe(1); + + // Switch to Cursor tab + await selectProviderTab(page, 'cursor'); + await page.waitForTimeout(300); + + // Custom profile should not appear under Cursor tab (it's a Claude profile) + const cursorCustomCount = await countCustomProfiles(page); + expect(cursorCustomCount).toBe(0); + }); + + test('should show correct profile count per provider', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + // Get Claude custom profile count + const claudeCustomCount = await countCustomProfiles(page); + + // Switch to Cursor + await selectProviderTab(page, 'cursor'); + await page.waitForTimeout(300); + + // Get Cursor custom profile count + const cursorCustomCount = await countCustomProfiles(page); + + // Both should start at 0 for custom profiles + expect(claudeCustomCount).toBe(0); + expect(cursorCustomCount).toBe(0); + }); +}); diff --git a/apps/ui/tests/utils/core/constants.ts b/apps/ui/tests/utils/core/constants.ts index f12ee0b8d..6a75f40be 100644 --- a/apps/ui/tests/utils/core/constants.ts +++ b/apps/ui/tests/utils/core/constants.ts @@ -100,6 +100,9 @@ export const TEST_IDS = { featureBranchInput: 'feature-input', featureCategoryInput: 'feature-category-input', worktreeSelector: 'worktree-selector', + providerSelect: 'provider-select', + providerOptionClaude: 'provider-select-option-claude', + providerOptionCursor: 'provider-select-option-cursor', // Spec Editor specEditor: 'spec-editor', diff --git a/apps/ui/tests/utils/project/setup.ts b/apps/ui/tests/utils/project/setup.ts index dacbbc1ff..35b700b7e 100644 --- a/apps/ui/tests/utils/project/setup.ts +++ b/apps/ui/tests/utils/project/setup.ts @@ -791,11 +791,12 @@ export async function setupMockProjectWithProfiles( // Default built-in profiles (same as DEFAULT_AI_PROFILES from app-store.ts) const builtInProfiles = [ + // Claude profiles { id: 'profile-heavy-task', name: 'Heavy Task', description: - 'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.', + 'Opus with Ultrathink for complex architecture, migrations, or deep debugging.', model: 'opus' as const, thinkingLevel: 'ultrathink' as const, provider: 'claude' as const, @@ -805,7 +806,7 @@ export async function setupMockProjectWithProfiles( { id: 'profile-balanced', name: 'Balanced', - description: 'Claude Sonnet with medium thinking for typical development tasks.', + description: 'Sonnet with medium thinking for typical development tasks.', model: 'sonnet' as const, thinkingLevel: 'medium' as const, provider: 'claude' as const, @@ -815,13 +816,45 @@ export async function setupMockProjectWithProfiles( { id: 'profile-quick-edit', name: 'Quick Edit', - description: 'Claude Haiku for fast, simple edits and minor fixes.', + description: 'Haiku for fast, simple edits and minor fixes.', model: 'haiku' as const, thinkingLevel: 'none' as const, provider: 'claude' as const, isBuiltIn: true, icon: 'Zap', }, + // Cursor profiles + { + id: 'profile-cursor-heavy-task', + name: 'Heavy Task', + description: + 'Opus with Ultrathink for complex architecture, migrations, or deep debugging.', + model: 'cursor-opus-thinking' as const, + thinkingLevel: 'ultrathink' as const, + provider: 'cursor' as const, + isBuiltIn: true, + icon: 'Brain', + }, + { + id: 'profile-cursor-balanced', + name: 'Balanced', + description: 'Sonnet with medium thinking for typical development tasks.', + model: 'cursor-sonnet' as const, + thinkingLevel: 'medium' as const, + provider: 'cursor' as const, + isBuiltIn: true, + icon: 'Scale', + }, + { + id: 'profile-cursor-quick-edit', + name: 'Quick Edit', + description: 'Composer for fast, simple edits and minor fixes.', + model: 'cursor-composer' as const, + thinkingLevel: 'none' as const, + provider: 'cursor' as const, + isBuiltIn: true, + icon: 'Zap', + }, ]; // Generate custom profiles if requested diff --git a/apps/ui/tests/utils/views/agent.ts b/apps/ui/tests/utils/views/agent.ts index cf8b7cfa4..d91657a9c 100644 --- a/apps/ui/tests/utils/views/agent.ts +++ b/apps/ui/tests/utils/views/agent.ts @@ -1,6 +1,48 @@ import { Page, Locator } from '@playwright/test'; import { waitForElement } from '../core/waiting'; +// ============================================================================ +// Model Selector Operations +// ============================================================================ + +/** + * Click the model selector dropdown button + */ +export async function clickModelSelector(page: Page): Promise { + const selector = page.locator('[data-testid="model-selector"]'); + await selector.click(); + // Wait for dropdown to open + await page.waitForTimeout(200); +} + +/** + * Select a model from the agent view model dropdown + * @param modelId - The model ID (e.g., 'sonnet', 'cursor-sonnet', 'opus') + */ +export async function selectAgentModel(page: Page, modelId: string): Promise { + await clickModelSelector(page); + const option = page.locator(`[data-testid="model-option-${modelId}"]`); + await option.click(); + // Wait for dropdown to close + await page.waitForTimeout(200); +} + +/** + * Get the currently selected model label from the selector button + */ +export async function getSelectedModelLabel(page: Page): Promise { + const selector = page.locator('[data-testid="model-selector"]'); + return (await selector.textContent()) || ''; +} + +/** + * Check if a specific model option is visible in the dropdown + */ +export async function isModelOptionVisible(page: Page, modelId: string): Promise { + const option = page.locator(`[data-testid="model-option-${modelId}"]`); + return await option.isVisible(); +} + /** * Get the session list element */ diff --git a/apps/ui/tests/utils/views/board.ts b/apps/ui/tests/utils/views/board.ts index d691b9146..f08638067 100644 --- a/apps/ui/tests/utils/views/board.ts +++ b/apps/ui/tests/utils/views/board.ts @@ -176,6 +176,72 @@ export async function addFeature( await confirmAddFeature(page); } +/** + * Select a provider in the add feature dialog + */ +export async function selectProvider(page: Page, providerId: 'claude' | 'cursor'): Promise { + await page.selectOption('[data-testid="provider-select"]', providerId); + await page.waitForTimeout(300); +} + +/** + * Fill add feature dialog with provider and model selection + */ +export async function fillAddFeatureDialogWithProvider( + page: Page, + description: string, + options?: { + provider?: 'claude' | 'cursor'; + model?: string; + branch?: string; + category?: string; + } +): Promise { + await page.locator('[data-testid="add-feature-dialog"] textarea').first().fill(description); + + if (options?.provider || options?.model) { + await page.click('[data-testid="tab-model"]'); + + if (options.provider) { + await selectProvider(page, options.provider); + } + + if (options.model) { + await page.click(`[data-testid="model-select-${options.model}"]`); + } + + await page.click('[data-testid="tab-prompt"]'); + } + + if (options?.branch) { + const otherBranchRadio = page + .locator('[data-testid="feature-radio-group"]') + .locator('[id="feature-other"]'); + await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 }); + await otherBranchRadio.click(); + await page.waitForTimeout(300); + + const branchInput = page.locator('[data-testid="feature-input"]'); + await branchInput.waitFor({ state: 'visible', timeout: 5000 }); + await branchInput.click(); + await page.waitForTimeout(300); + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill(options.branch); + await commandInput.press('Enter'); + await page.waitForTimeout(200); + } + + if (options?.category) { + const categoryButton = page.locator('[data-testid="feature-category-input"]'); + await categoryButton.click(); + await page.waitForTimeout(300); + const commandInput = page.locator('[cmdk-input]'); + await commandInput.fill(options.category); + await commandInput.press('Enter'); + await page.waitForTimeout(200); + } +} + // ============================================================================ // Worktree Selector // ============================================================================ diff --git a/apps/ui/tests/utils/views/profiles.ts b/apps/ui/tests/utils/views/profiles.ts index d03e1c345..7402b1182 100644 --- a/apps/ui/tests/utils/views/profiles.ts +++ b/apps/ui/tests/utils/views/profiles.ts @@ -4,6 +4,38 @@ import { waitForElement, waitForElementHidden } from '../core/waiting'; import { getByTestId } from '../core/elements'; import { navigateToView } from '../navigation/views'; +// ============================================================================ +// Provider Tab Operations +// ============================================================================ + +/** + * Select a provider tab in the profiles view + * @param provider - 'claude' or 'cursor' + */ +export async function selectProviderTab(page: Page, provider: 'claude' | 'cursor'): Promise { + const tab = page.locator(`[data-testid="provider-tab-${provider}"]`); + await tab.click(); + // Wait for tab to become active + await tab.waitFor({ state: 'visible' }); +} + +/** + * Get the currently active provider tab + * @returns 'claude' or 'cursor' + */ +export async function getActiveProviderTab(page: Page): Promise<'claude' | 'cursor' | null> { + const claudeTab = page.locator('[data-testid="provider-tab-claude"]'); + const cursorTab = page.locator('[data-testid="provider-tab-cursor"]'); + + const claudeState = await claudeTab.getAttribute('data-state'); + if (claudeState === 'active') return 'claude'; + + const cursorState = await cursorTab.getAttribute('data-state'); + if (cursorState === 'active') return 'cursor'; + + return null; +} + /** * Navigate to the profiles view */ diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 64962d2ae..94bea4c30 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -33,7 +33,16 @@ export type { ErrorType, ErrorInfo } from './error.js'; export type { ImageData, ImageContentBlock } from './image.js'; // Model types and constants -export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias, type AgentModel } from './model.js'; +export { + CLAUDE_MODEL_MAP, + CURSOR_MODEL_MAP, + ALL_MODEL_MAP, + DEFAULT_MODELS, + type ClaudeModelAlias, + type CursorModelAlias, + type ModelAlias, + type AgentModel, +} from './model.js'; // Event types export type { EventType, EventCallback } from './event.js'; diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index ab186bafd..2eb167ef8 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -7,17 +7,39 @@ export const CLAUDE_MODEL_MAP: Record = { opus: 'claude-opus-4-5-20251101', } as const; +/** + * Model alias mapping for Cursor models + */ +export const CURSOR_MODEL_MAP: Record = { + 'cursor-opus-thinking': 'cursor-opus-4.5-thinking', + 'cursor-sonnet': 'cursor-sonnet-4.5', + 'cursor-gpt5': 'cursor-gpt-5.2', + 'cursor-composer': 'cursor-composer', +} as const; + +/** + * Combined model map for all providers + */ +export const ALL_MODEL_MAP: Record = { + ...CLAUDE_MODEL_MAP, + ...CURSOR_MODEL_MAP, +} as const; + /** * Default models per provider */ export const DEFAULT_MODELS = { claude: 'claude-opus-4-5-20251101', + cursor: 'cursor-opus-4.5-thinking', } as const; -export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP; +export type ClaudeModelAlias = keyof typeof CLAUDE_MODEL_MAP; +export type CursorModelAlias = keyof typeof CURSOR_MODEL_MAP; +export type ModelAlias = ClaudeModelAlias | CursorModelAlias; /** - * AgentModel - Alias for ModelAlias for backward compatibility - * Represents available Claude models: "opus" | "sonnet" | "haiku" + * AgentModel - Represents all available models across providers + * Claude models: "opus" | "sonnet" | "haiku" + * Cursor models: "cursor-opus-thinking" | "cursor-sonnet" | "cursor-gpt5" | "cursor-composer" */ export type AgentModel = ModelAlias; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index e73e72697..40d1e04a1 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -68,7 +68,7 @@ export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; export type ThinkingLevel = 'none' | 'low' | 'medium' | 'high' | 'ultrathink'; /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude'; +export type ModelProvider = 'claude' | 'cursor'; /** * WindowBounds - Electron window position and size for persistence