feat(ui): Enhance AI model handling with Cursor support

- Refactor model handling to support both Claude and Cursor models across various components.
- Introduce `stripProviderPrefix` utility for consistent model ID processing.
- Update `CursorProvider` to utilize `isCursorModel` for model validation.
- Implement model override functionality in GitHub issue validation and enhancement routes.
- Add `useCursorStatusInit` hook to initialize Cursor CLI status on app startup.
- Update UI components to reflect changes in model selection and validation processes.

This update improves the flexibility of AI model usage and enhances user experience by allowing quick model overrides.
This commit is contained in:
Kacper
2025-12-30 04:01:56 +01:00
parent 3d655c3298
commit 39f2c8c9ff
38 changed files with 713 additions and 258 deletions

View File

@@ -28,6 +28,7 @@ import type {
ModelDefinition,
ContentBlock,
} from './types.js';
import { stripProviderPrefix } from '@automaker/types';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -115,10 +116,7 @@ export class CursorProvider extends CliProvider {
buildCliArgs(options: ExecuteOptions): string[] {
// Extract model (strip 'cursor-' prefix if present)
let model = options.model || 'auto';
if (model.startsWith('cursor-')) {
model = model.substring(7);
}
const model = stripProviderPrefix(options.model || 'auto');
// Build prompt content
let promptText: string;

View File

@@ -7,7 +7,7 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { CURSOR_MODEL_MAP, type ModelProvider } from '@automaker/types';
import { isCursorModel, type ModelProvider } from '@automaker/types';
/**
* Provider registration entry
@@ -181,14 +181,6 @@ registerProvider('claude', {
// Register Cursor provider
registerProvider('cursor', {
factory: () => new CursorProvider(),
canHandleModel: (model: string) => {
// Check for explicit cursor prefix
if (model.startsWith('cursor-')) {
return true;
}
// Check if it's a known Cursor model ID
const modelId = model.replace('cursor-', '');
return modelId in CURSOR_MODEL_MAP;
},
canHandleModel: (model: string) => isCursorModel(model),
priority: 10, // Higher priority - check Cursor models first
});

View File

@@ -1,7 +1,7 @@
/**
* POST /enhance-prompt endpoint - Enhance user input text
*
* Uses Claude AI to enhance text based on the specified enhancement mode.
* Uses Claude AI or Cursor to enhance text based on the specified enhancement mode.
* Supports modes: improve, technical, simplify, acceptance
*/
@@ -9,7 +9,8 @@ import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
import { CLAUDE_MODEL_MAP, isCursorModel } from '@automaker/types';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import {
getSystemPrompt,
buildUserPrompt,
@@ -80,6 +81,40 @@ async function extractTextFromStream(
return responseText;
}
/**
* Execute enhancement using Cursor provider
*
* @param prompt - The enhancement prompt
* @param model - The Cursor model to use
* @returns The enhanced text
*/
async function executeWithCursor(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
let responseText = '';
for await (const msg of provider.executeQuery({
prompt,
model,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
return responseText;
}
/**
* Create the enhance request handler
*
@@ -140,24 +175,36 @@ export function createEnhanceHandler(): (req: Request, res: Response) => Promise
logger.debug(`Using model: ${resolvedModel}`);
// Call Claude SDK with minimal configuration for text transformation
// Key: no tools, just text completion
const stream = query({
prompt: userPrompt,
options: {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
});
let enhancedText: string;
// Extract the enhanced text from the response
const enhancedText = await extractTextFromStream(stream);
// Route to appropriate provider based on model
if (isCursorModel(resolvedModel)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${resolvedModel}`);
// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);
const stream = query({
prompt: userPrompt,
options: {
model: resolvedModel,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
},
});
enhancedText = await extractTextFromStream(stream);
}
if (!enhancedText || enhancedText.trim().length === 0) {
logger.warn('Received empty response from Claude');
logger.warn('Received empty response from AI');
const response: EnhanceErrorResponse = {
success: false,
error: 'Failed to generate enhanced text - empty response',

View File

@@ -1,16 +1,24 @@
/**
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK or Cursor (async)
*
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
* Runs asynchronously and emits events for progress and completion.
* Supports both Claude models and Cursor models.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
import type {
IssueValidationResult,
IssueValidationEvent,
ModelAlias,
CursorModelId,
} from '@automaker/types';
import { isCursorModel } from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
@@ -26,8 +34,8 @@ import {
import type { SettingsService } from '../../../services/settings-service.js';
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/** Valid model values for validation */
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
/** Valid Claude model values for validation */
const VALID_CLAUDE_MODELS: readonly ModelAlias[] = ['opus', 'sonnet', 'haiku'] as const;
/**
* Request body for issue validation
@@ -38,8 +46,8 @@ interface ValidateIssueRequestBody {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku) */
model?: AgentModel;
/** Model to use for validation (opus, sonnet, haiku, or cursor model IDs) */
model?: ModelAlias | CursorModelId;
}
/**
@@ -47,6 +55,7 @@ interface ValidateIssueRequestBody {
*
* Emits events for start, progress, complete, and error.
* Stores result on completion.
* Supports both Claude models (with structured output) and Cursor models (with JSON parsing).
*/
async function runValidation(
projectPath: string,
@@ -54,7 +63,7 @@ async function runValidation(
issueTitle: string,
issueBody: string,
issueLabels: string[] | undefined,
model: AgentModel,
model: ModelAlias | CursorModelId,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
@@ -79,65 +88,133 @@ async function runValidation(
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
model,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
let validationResult: IssueValidationResult | null = null;
let responseText = '';
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Route to appropriate provider based on model
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for validation with model: ${model}`);
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
const provider = ProviderFactory.getProviderForModel(model);
// For Cursor, include the system prompt and schema in the user prompt
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
You MUST respond with a valid JSON object matching this schema:
${JSON.stringify(issueValidationSchema, null, 2)}
${prompt}`;
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model,
cwd: projectPath,
})) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
// Parse JSON from the response text
if (responseText) {
try {
// Try to extract JSON from response (it might be wrapped in markdown code blocks)
let jsonStr = responseText;
// Remove markdown code blocks if present
const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
validationResult = JSON.parse(jsonStr) as IssueValidationResult;
logger.debug('Parsed validation result from Cursor response:', validationResult);
} catch (parseError) {
logger.error('Failed to parse JSON from Cursor response:', parseError);
logger.debug('Raw response:', responseText);
}
}
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for validation with model: ${model}`);
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[ValidateIssue]'
);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
model: model as ModelAlias,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
autoLoadClaudeMd,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
}
}
}
}
@@ -145,11 +222,11 @@ async function runValidation(
// Clear timeout
clearTimeout(timeoutId);
// Require structured output
// Require validation result
if (!validationResult) {
logger.error('No structured output received from Claude SDK');
logger.error('No validation result received from AI provider');
logger.debug('Raw response text:', responseText);
throw new Error('Validation failed: no structured output received');
throw new Error('Validation failed: no valid result received');
}
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
@@ -239,11 +316,14 @@ export function createValidateIssueHandler(
return;
}
// Validate model parameter at runtime
if (!VALID_MODELS.includes(model)) {
// Validate model parameter at runtime - accept Claude models or Cursor models
const isValidClaudeModel = VALID_CLAUDE_MODELS.includes(model as ModelAlias);
const isValidCursorModel = isCursorModel(model);
if (!isValidClaudeModel && !isValidCursorModel) {
res.status(400).json({
success: false,
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
error: `Invalid model. Must be one of: ${VALID_CLAUDE_MODELS.join(', ')}, or a Cursor model ID`,
});
return;
}

View File

@@ -8,7 +8,7 @@
export type {
ThemeMode,
KanbanCardDetailLevel,
AgentModel,
ModelAlias,
PlanningMode,
ThinkingLevel,
ModelProvider,

View File

@@ -3,6 +3,7 @@ import { RouterProvider } from '@tanstack/react-router';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { useSettingsMigration } from './hooks/use-settings-migration';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import './styles/global.css';
import './styles/theme-imports';
@@ -21,6 +22,9 @@ export default function App() {
console.log('[App] Settings migrated to file storage');
}
// Initialize Cursor CLI status at startup
useCursorStatusInit();
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem('automaker-splash-shown', 'true');
setShowSplash(false);

View File

@@ -4,14 +4,15 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
import type { AgentModel, CursorModelId, PhaseModelKey } from '@automaker/types';
import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types';
import { PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
export interface ModelOverrideTriggerProps {
/** Current effective model (from global settings or explicit override) */
currentModel: AgentModel | CursorModelId;
currentModel: ModelAlias | CursorModelId;
/** Callback when user selects override */
onModelChange: (model: AgentModel | CursorModelId | null) => void;
onModelChange: (model: ModelAlias | CursorModelId | null) => void;
/** Optional: which phase this is for (shows global default) */
phase?: PhaseModelKey;
/** Size variants for different contexts */
@@ -24,13 +25,13 @@ export interface ModelOverrideTriggerProps {
className?: string;
}
function getModelLabel(modelId: AgentModel | CursorModelId): string {
function getModelLabel(modelId: ModelAlias | CursorModelId): string {
// Check Claude models
const claudeModel = CLAUDE_MODELS.find((m) => m.id === modelId);
if (claudeModel) return claudeModel.label;
// Check Cursor models (without cursor- prefix)
const cursorModel = CURSOR_MODELS.find((m) => m.id === `cursor-${modelId}`);
const cursorModel = CURSOR_MODELS.find((m) => m.id === `${PROVIDER_PREFIXES.cursor}${modelId}`);
if (cursorModel) return cursorModel.label;
// Check Cursor models (with cursor- prefix)
@@ -57,11 +58,11 @@ export function ModelOverrideTrigger({
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
return enabledCursorModels.includes(cursorId);
});
const handleSelect = (model: AgentModel | CursorModelId) => {
const handleSelect = (model: ModelAlias | CursorModelId) => {
onModelChange(model);
setOpen(false);
};
@@ -162,7 +163,7 @@ export function ModelOverrideTrigger({
return (
<button
key={model.id}
onClick={() => handleSelect(model.id as AgentModel)}
onClick={() => handleSelect(model.id as ModelAlias)}
className={cn(
'px-3 py-2 rounded-lg text-xs font-medium text-center',
'transition-all duration-150',
@@ -190,7 +191,7 @@ export function ModelOverrideTrigger({
</h5>
<div className="grid grid-cols-2 gap-2">
{availableCursorModels.slice(0, 6).map((model) => {
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const isActive = currentModel === cursorId;
return (
<button

View File

@@ -1,27 +1,27 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { AgentModel, CursorModelId, PhaseModelKey } from '@automaker/types';
import type { ModelAlias, CursorModelId, PhaseModelKey } from '@automaker/types';
export interface UseModelOverrideOptions {
/** Which phase this override is for */
phase: PhaseModelKey;
/** Initial override value (optional) */
initialOverride?: AgentModel | CursorModelId | null;
initialOverride?: ModelAlias | CursorModelId | null;
}
export interface UseModelOverrideResult {
/** The effective model (override or global default) */
effectiveModel: AgentModel | CursorModelId;
effectiveModel: ModelAlias | CursorModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
setOverride: (model: AgentModel | CursorModelId | null) => void;
setOverride: (model: ModelAlias | CursorModelId | null) => void;
/** Clear the override and use global default */
clearOverride: () => void;
/** The global default for this phase */
globalDefault: AgentModel | CursorModelId;
globalDefault: ModelAlias | CursorModelId;
/** The current override value (null if not overridden) */
override: AgentModel | CursorModelId | null;
override: ModelAlias | CursorModelId | null;
}
/**
@@ -53,7 +53,7 @@ export function useModelOverride({
initialOverride = null,
}: UseModelOverrideOptions): UseModelOverrideResult {
const { phaseModels } = useAppStore();
const [override, setOverrideState] = useState<AgentModel | CursorModelId | null>(initialOverride);
const [override, setOverrideState] = useState<ModelAlias | CursorModelId | null>(initialOverride);
const globalDefault = phaseModels[phase];
@@ -63,7 +63,7 @@ export function useModelOverride({
const isOverridden = override !== null;
const setOverride = useCallback((model: AgentModel | CursorModelId | null) => {
const setOverride = useCallback((model: ModelAlias | CursorModelId | null) => {
setOverrideState(model);
}, []);

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useAppStore, type AgentModel } from '@/store/app-store';
import { useAppStore, type ModelAlias } from '@/store/app-store';
import type { CursorModelId } from '@automaker/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ImageDropZone } from '@/components/ui/image-drop-zone';
@@ -63,7 +64,7 @@ export function AgentView() {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
const [selectedModel, setSelectedModel] = useState<ModelAlias | CursorModelId>('sonnet');
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);

View File

@@ -9,7 +9,7 @@ import {
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import type { BacklogPlanResult } from '@automaker/types';
import type { ModelAlias, CursorModelId, BacklogPlanResult } from '@automaker/types';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';

View File

@@ -25,7 +25,7 @@ import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
AgentModel,
ModelAlias,
ThinkingLevel,
FeatureImage,
AIProfile,
@@ -42,6 +42,7 @@ import {
PlanningModeSelector,
AncestorContextSection,
} from '../shared';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,6 +55,7 @@ import {
formatAncestorContextForPrompt,
type AncestorContext,
} from '@automaker/dependency-resolver';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
interface AddFeatureDialogProps {
open: boolean;
@@ -66,7 +68,7 @@ interface AddFeatureDialogProps {
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string; // Can be empty string to use current branch
priority: number;
@@ -115,7 +117,7 @@ export function AddFeatureDialog({
imagePaths: [] as DescriptionImagePath[],
textFilePaths: [] as DescriptionTextFilePath[],
skipTests: false,
model: 'opus' as AgentModel,
model: 'opus' as ModelAlias,
thinkingLevel: 'none' as ThinkingLevel,
branchName: '',
priority: 2 as number, // Default to medium priority
@@ -136,14 +138,12 @@ export function AddFeatureDialog({
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get enhancement model, planning mode defaults, and worktrees setting from store
const {
enhancementModel,
defaultPlanningMode,
defaultRequirePlanApproval,
defaultAIProfileId,
useWorktrees,
} = useAppStore();
// Get planning mode defaults and worktrees setting from store
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } =
useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Sync defaults when dialog opens
useEffect(() => {
@@ -294,7 +294,7 @@ export function AddFeatureDialog({
const result = await api.enhancePrompt?.enhance(
newFeature.description,
enhancementMode,
enhancementModel
enhancementOverride.effectiveModel
);
if (result?.success && result.enhancedText) {
@@ -315,11 +315,11 @@ export function AddFeatureDialog({
const handleModelSelect = (model: string) => {
// For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking
const isCursorModel = model.startsWith('cursor-');
const isCursor = isCursorModel(model);
setNewFeature({
...newFeature,
model: model as AgentModel,
thinkingLevel: isCursorModel
model: model as ModelAlias,
thinkingLevel: isCursor
? 'none'
: modelSupportsThinking(model)
? newFeature.thinkingLevel
@@ -330,10 +330,10 @@ export function AddFeatureDialog({
const handleProfileSelect = (profile: AIProfile) => {
if (profile.provider === 'cursor') {
// Cursor profile - set cursor model
const cursorModel = `cursor-${profile.cursorModel || 'auto'}`;
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setNewFeature({
...newFeature,
model: cursorModel as AgentModel,
model: cursorModel as ModelAlias,
thinkingLevel: 'none', // Cursor handles thinking internally
});
} else {
@@ -347,8 +347,9 @@ export function AddFeatureDialog({
};
// Cursor models handle thinking internally, so only show thinking selector for Claude models
const isCursorModel = newFeature.model.startsWith('cursor-');
const newModelAllowsThinking = !isCursorModel && modelSupportsThinking(newFeature.model);
const isCurrentModelCursor = isCursorModel(newFeature.model);
const newModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet');
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
@@ -480,6 +481,15 @@ export function AddFeatureDialog({
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModel={enhancementOverride.effectiveModel}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category (optional)</Label>
@@ -540,7 +550,7 @@ export function AddFeatureDialog({
profiles={aiProfiles}
selectedModel={newFeature.model}
selectedThinkingLevel={newFeature.thinkingLevel}
selectedCursorModel={isCursorModel ? newFeature.model : undefined}
selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined}
onSelect={handleProfileSelect}
showManageLink
onManageLinkClick={() => {

View File

@@ -32,7 +32,7 @@ import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
ModelAlias,
ThinkingLevel,
AIProfile,
useAppStore,
@@ -47,6 +47,7 @@ import {
BranchSelector,
PlanningModeSelector,
} from '../shared';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,6 +55,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -65,7 +67,7 @@ interface EditFeatureDialogProps {
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[];
@@ -117,8 +119,11 @@ export function EditFeatureDialog({
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
// Get worktrees setting from store
const { useWorktrees } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
useEffect(() => {
setEditingFeature(feature);
@@ -148,7 +153,7 @@ export function EditFeatureDialog({
return;
}
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const selectedModel = (editingFeature.model ?? 'opus') as ModelAlias;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
: 'none';
@@ -187,22 +192,40 @@ export function EditFeatureDialog({
}
};
const handleModelSelect = (model: AgentModel) => {
const handleModelSelect = (model: string) => {
if (!editingFeature) return;
// For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking
const isCursor = isCursorModel(model);
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
model: model as ModelAlias,
thinkingLevel: isCursor
? 'none'
: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: 'none',
});
};
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
const handleProfileSelect = (profile: AIProfile) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
model,
thinkingLevel,
});
if (profile.provider === 'cursor') {
// Cursor profile - set cursor model
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
setEditingFeature({
...editingFeature,
model: cursorModel as ModelAlias,
thinkingLevel: 'none', // Cursor handles thinking internally
});
} else {
// Claude profile
setEditingFeature({
...editingFeature,
model: profile.model || 'sonnet',
thinkingLevel: profile.thinkingLevel || 'none',
});
}
};
const handleEnhanceDescription = async () => {
@@ -214,7 +237,7 @@ export function EditFeatureDialog({
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementModel
enhancementOverride.effectiveModel
);
if (result?.success && result.enhancedText) {
@@ -232,7 +255,10 @@ export function EditFeatureDialog({
}
};
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
// Cursor models handle thinking internally, so only show thinking selector for Claude models
const isCurrentModelCursor = isCursorModel(editingFeature?.model as string);
const editModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(editingFeature?.model);
if (!editingFeature) {
return null;
@@ -361,6 +387,15 @@ export function EditFeatureDialog({
<Sparkles className="w-4 h-4 mr-2" />
Enhance with AI
</Button>
<ModelOverrideTrigger
currentModel={enhancementOverride.effectiveModel}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>
@@ -437,6 +472,9 @@ export function EditFeatureDialog({
profiles={aiProfiles}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
selectedCursorModel={
isCurrentModelCursor ? (editingFeature.model as string) : undefined
}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
@@ -450,7 +488,7 @@ export function EditFeatureDialog({
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as ModelAlias}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import {
Feature,
FeatureImage,
AgentModel,
ModelAlias,
ThinkingLevel,
PlanningMode,
useAppStore,
@@ -92,7 +92,7 @@ export function useBoardActions({
images: FeatureImage[];
imagePaths: DescriptionImagePath[];
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
branchName: string;
priority: number;
@@ -210,7 +210,7 @@ export function useBoardActions({
category: string;
description: string;
skipTests: boolean;
model: AgentModel;
model: ModelAlias;
thinkingLevel: ThinkingLevel;
imagePaths: DescriptionImagePath[];
branchName: string;

View File

@@ -1,10 +1,10 @@
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
import type { ModelProvider } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
export type ModelOption = {
id: string; // Claude models use AgentModel, Cursor models use "cursor-{id}"
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
label: string;
description: string;
badge?: string;

View File

@@ -2,28 +2,19 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AgentModel } from '@/store/app-store';
import type { ModelAlias } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types';
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
interface ModelSelectorProps {
selectedModel: string; // Can be AgentModel or "cursor-{id}"
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
onModelSelect: (model: string) => void;
testIdPrefix?: string;
}
/**
* Get the provider from a model string
*/
function getProviderFromModelString(model: string): ModelProvider {
if (model.startsWith('cursor-')) {
return 'cursor';
}
return 'claude';
}
export function ModelSelector({
selectedModel,
onModelSelect,
@@ -32,7 +23,7 @@ export function ModelSelector({
const { enabledCursorModels, cursorDefaultModel } = useAppStore();
const { cursorCliStatus } = useSetupStore();
const selectedProvider = getProviderFromModelString(selectedModel);
const selectedProvider = getModelProvider(selectedModel);
// Check if Cursor CLI is available
const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated;
@@ -40,14 +31,14 @@ export function ModelSelector({
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
const cursorModelId = model.id.replace('cursor-', '');
const cursorModelId = stripProviderPrefix(model.id);
return enabledCursorModels.includes(cursorModelId as any);
});
const handleProviderChange = (provider: ModelProvider) => {
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`cursor-${cursorDefaultModel}`);
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');

View File

@@ -1,8 +1,8 @@
import { Label } from '@/components/ui/label';
import { Brain, UserCircle, Terminal } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AgentModel, ThinkingLevel, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking } from '@automaker/types';
import type { ModelAlias, ThinkingLevel, AIProfile, CursorModelId } from '@automaker/types';
import { CURSOR_MODEL_MAP, profileHasThinking, PROVIDER_PREFIXES } from '@automaker/types';
import { PROFILE_ICONS } from './model-constants';
/**
@@ -32,7 +32,7 @@ function getProfileThinkingDisplay(profile: AIProfile): string | null {
interface ProfileQuickSelectProps {
profiles: AIProfile[];
selectedModel: AgentModel;
selectedModel: ModelAlias | CursorModelId;
selectedThinkingLevel: ThinkingLevel;
selectedCursorModel?: string; // For detecting cursor profile selection
onSelect: (profile: AIProfile) => void; // Changed to pass full profile
@@ -62,7 +62,7 @@ export function ProfileQuickSelect({
const isProfileSelected = (profile: AIProfile): boolean => {
if (profile.provider === 'cursor') {
// For cursor profiles, check if cursor model matches
const profileCursorModel = `cursor-${profile.cursorModel || 'auto'}`;
const profileCursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
return selectedCursorModel === profileCursorModel;
}
// For Claude profiles

View File

@@ -11,6 +11,7 @@ import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared';
export function GitHubIssuesView() {
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
@@ -21,6 +22,9 @@ export function GitHubIssuesView() {
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
@@ -85,6 +89,9 @@ export function GitHubIssuesView() {
.filter(Boolean)
.join('\n');
// Use profile default model
const featureModel = defaultProfile?.model ?? 'opus';
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
title: issue.title,
@@ -93,7 +100,7 @@ export function GitHubIssuesView() {
status: 'backlog' as const,
passes: false,
priority: getFeaturePriority(validation.estimatedComplexity),
model: defaultProfile?.model ?? 'opus',
model: featureModel,
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
branchName: currentBranch,
createdAt: new Date().toISOString(),
@@ -205,6 +212,7 @@ export function GitHubIssuesView() {
onClose={() => setSelectedIssue(null)}
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
formatDate={formatDate}
modelOverride={validationModelOverride}
/>
)}
@@ -228,7 +236,10 @@ export function GitHubIssuesView() {
confirmText="Re-validate"
onConfirm={() => {
if (selectedIssue) {
handleValidateIssue(selectedIssue, { forceRevalidate: true });
handleValidateIssue(selectedIssue, {
forceRevalidate: true,
model: validationModelOverride.effectiveModel,
});
}
}}
/>

View File

@@ -16,6 +16,7 @@ import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
import { ModelOverrideTrigger } from '@/components/shared';
export function IssueDetailPanel({
issue,
@@ -27,6 +28,7 @@ export function IssueDetailPanel({
onClose,
onShowRevalidateConfirm,
formatDate,
modelOverride,
}: IssueDetailPanelProps) {
const isValidating = validatingIssues.has(issue.number);
const cached = cachedValidations.get(issue.number);
@@ -83,10 +85,23 @@ export function IssueDetailPanel({
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
</Button>
<ModelOverrideTrigger
currentModel={modelOverride.effectiveModel}
onModelChange={modelOverride.setOverride}
phase="validationModel"
isOverridden={modelOverride.isOverridden}
size="sm"
variant="icon"
/>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
onClick={() =>
onValidateIssue(issue, {
forceRevalidate: true,
model: modelOverride.effectiveModel,
})
}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
@@ -96,10 +111,24 @@ export function IssueDetailPanel({
}
return (
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
<>
<ModelOverrideTrigger
currentModel={modelOverride.effectiveModel}
onModelChange={modelOverride.setOverride}
phase="validationModel"
isOverridden={modelOverride.isOverridden}
size="sm"
variant="icon"
/>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { model: modelOverride.effectiveModel })}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
</>
);
})()}
<Button variant="outline" size="sm" onClick={() => onOpenInGitHub(issue.url)}>

View File

@@ -205,8 +205,8 @@ export function useIssueValidation({
}, []);
const handleValidateIssue = useCallback(
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
const { forceRevalidate = false } = options;
async (issue: GitHubIssue, options: { forceRevalidate?: boolean; model?: string } = {}) => {
const { forceRevalidate = false, model } = options;
if (!currentProject?.path) {
toast.error('No project selected');
@@ -233,6 +233,9 @@ export function useIssueValidation({
description: 'You will be notified when the analysis is complete',
});
// Use provided model override or fall back to global validationModel
const modelToUse = model || validationModel;
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
@@ -244,7 +247,7 @@ export function useIssueValidation({
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationModel
modelToUse
);
if (!result.success) {

View File

@@ -1,4 +1,5 @@
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { ModelAlias, CursorModelId } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -18,11 +19,21 @@ export interface IssueDetailPanelProps {
cachedValidations: Map<number, StoredValidation>;
onValidateIssue: (
issue: GitHubIssue,
options?: { showDialog?: boolean; forceRevalidate?: boolean }
options?: {
showDialog?: boolean;
forceRevalidate?: boolean;
model?: ModelAlias | CursorModelId;
}
) => Promise<void>;
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
onOpenInGitHub: (url: string) => void;
onClose: () => void;
onShowRevalidateConfirm: () => void;
formatDate: (date: string) => string;
/** Model override state */
modelOverride: {
effectiveModel: ModelAlias | CursorModelId;
isOverridden: boolean;
setOverride: (model: ModelAlias | CursorModelId | null) => void;
};
}

View File

@@ -11,7 +11,7 @@ import { Brain, Bot, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import type {
AIProfile,
AgentModel,
ModelAlias,
ThinkingLevel,
ModelProvider,
CursorModelId,
@@ -42,7 +42,7 @@ export function ProfileForm({
description: profile.description || '',
provider: (profile.provider || 'claude') as ModelProvider,
// Claude-specific
model: profile.model || ('sonnet' as AgentModel),
model: profile.model || ('sonnet' as ModelAlias),
thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel),
// Cursor-specific
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
@@ -62,7 +62,7 @@ export function ProfileForm({
});
};
const handleModelChange = (model: AgentModel) => {
const handleModelChange = (model: ModelAlias) => {
setFormData({
...formData,
model,

View File

@@ -1,5 +1,5 @@
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
import type { ModelAlias, ThinkingLevel } from '@/store/app-store';
// Icon mapping for profiles
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -22,7 +22,7 @@ export const ICON_OPTIONS = [
];
// Model options for the form
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
export const CLAUDE_MODELS: { id: ModelAlias; label: string }[] = [
{ id: 'haiku', label: 'Claude Haiku' },
{ id: 'sonnet', label: 'Claude Sonnet' },
{ id: 'opus', label: 'Claude Opus' },

View File

@@ -1,8 +1,8 @@
import type { AgentModel, ModelProvider, AIProfile } from '@automaker/types';
import type { ModelAlias, ModelProvider, AIProfile } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
// Helper to determine provider from model (legacy, always returns 'claude')
export function getProviderFromModel(model: AgentModel): ModelProvider {
export function getProviderFromModel(model: ModelAlias): ModelProvider {
return 'claude';
}

View File

@@ -23,7 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
import type { AgentModel } from '@automaker/types';
import type { ModelAlias } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -36,7 +36,7 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
validationModel: AgentModel;
validationModel: ModelAlias;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -44,7 +44,7 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
onValidationModelChange: (value: AgentModel) => void;
onValidationModelChange: (value: ModelAlias) => void;
}
export function FeatureDefaultsSection({
@@ -243,7 +243,7 @@ export function FeatureDefaultsSection({
<Label className="text-foreground font-medium">Issue Validation Model</Label>
<Select
value={validationModel}
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
onValueChange={(v: string) => onValidationModelChange(v as ModelAlias)}
>
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
<SelectValue />

View File

@@ -1,13 +1,14 @@
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { AgentModel, CursorModelId } from '@automaker/types';
import type { ModelAlias, CursorModelId } from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
interface PhaseModelSelectorProps {
label: string;
description: string;
value: AgentModel | CursorModelId;
onChange: (model: AgentModel | CursorModelId) => void;
value: ModelAlias | CursorModelId;
onChange: (model: ModelAlias | CursorModelId) => void;
}
export function PhaseModelSelector({
@@ -20,13 +21,10 @@ export function PhaseModelSelector({
// Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
return enabledCursorModels.includes(cursorId);
});
// Check if current value is a Claude model or Cursor model
const isClaudeModel = (v: string) => ['haiku', 'sonnet', 'opus'].includes(v);
return (
<div
className={cn(
@@ -50,7 +48,7 @@ export function PhaseModelSelector({
return (
<button
key={model.id}
onClick={() => onChange(model.id as AgentModel)}
onClick={() => onChange(model.id as ModelAlias)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium',
'transition-all duration-150',
@@ -75,7 +73,7 @@ export function PhaseModelSelector({
{/* Cursor Models */}
{availableCursorModels.map((model) => {
const cursorId = model.id.replace('cursor-', '') as CursorModelId;
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const isActive = value === cursorId;
return (
<button

View File

@@ -13,6 +13,7 @@ import { Terminal, Info } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { cn } from '@/lib/utils';
import type { CursorModelId, CursorModelConfig } from '@automaker/types';
import { CURSOR_MODEL_MAP } from '@automaker/types';
@@ -33,6 +34,7 @@ export function CursorSettingsTab() {
// Global settings from store
const { enabledCursorModels, cursorDefaultModel, setCursorDefaultModel, toggleCursorModel } =
useAppStore();
const { setCursorCliStatus } = useSetupStore();
const [status, setStatus] = useState<CursorStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -48,11 +50,24 @@ export function CursorSettingsTab() {
const statusResult = await api.setup.getCursorStatus();
if (statusResult.success) {
setStatus({
const newStatus = {
installed: statusResult.installed ?? false,
version: statusResult.version ?? undefined,
authenticated: statusResult.auth?.authenticated ?? false,
method: statusResult.auth?.method,
};
setStatus(newStatus);
// Also update the global setup store so other components can access the status
setCursorCliStatus({
installed: newStatus.installed,
version: newStatus.version,
auth: newStatus.authenticated
? {
authenticated: true,
method: newStatus.method || 'unknown',
}
: undefined,
});
}
} catch (error) {
@@ -61,7 +76,7 @@ export function CursorSettingsTab() {
} finally {
setIsLoading(false);
}
}, []);
}, [setCursorCliStatus]);
useEffect(() => {
loadData();

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react';
import { useSetupStore } from '@/store/setup-store';
import { getHttpApiClient } from '@/lib/http-api-client';
/**
* Hook to initialize Cursor CLI status on app startup.
* This ensures the cursorCliStatus is available in the setup store
* before the user opens feature dialogs.
*/
export function useCursorStatusInit() {
const { setCursorCliStatus, cursorCliStatus } = useSetupStore();
const initialized = useRef(false);
useEffect(() => {
// Only initialize once per session
if (initialized.current || cursorCliStatus !== null) {
return;
}
initialized.current = true;
const initCursorStatus = async () => {
try {
const api = getHttpApiClient();
const statusResult = await api.setup.getCursorStatus();
if (statusResult.success) {
setCursorCliStatus({
installed: statusResult.installed ?? false,
version: statusResult.version ?? undefined,
auth: statusResult.auth?.authenticated
? {
authenticated: true,
method: statusResult.auth.method || 'unknown',
}
: undefined,
});
}
} catch (error) {
// Silently fail - cursor is optional
console.debug('[CursorStatusInit] Failed to check cursor status:', error);
}
};
initCursorStatus();
}, [setCursorCliStatus, cursorCliStatus]);
}

View File

@@ -10,7 +10,7 @@ import type {
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
AgentModel,
ModelAlias,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -198,7 +198,7 @@ export interface GitHubAPI {
validateIssue: (
projectPath: string,
issue: IssueValidationInput,
model?: AgentModel
model?: ModelAlias
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
/** Check validation status for an issue or all issues */
getValidationStatus: (
@@ -2734,7 +2734,7 @@ function createMockGitHubAPI(): GitHubAPI {
mergedPRs: [],
};
},
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: ModelAlias) => {
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
// Simulate async validation in background

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AgentModel } from '@/store/app-store';
import type { ModelAlias } from '@/store/app-store';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -9,7 +9,7 @@ export function cn(...inputs: ClassValue[]) {
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(_model?: AgentModel | string): boolean {
export function modelSupportsThinking(_model?: ModelAlias | string): boolean {
// All Claude models support thinking
return true;
}
@@ -17,7 +17,7 @@ export function modelSupportsThinking(_model?: AgentModel | string): boolean {
/**
* Get display name for a model
*/
export function getModelDisplayName(model: AgentModel | string): string {
export function getModelDisplayName(model: ModelAlias | string): string {
const displayNames: Record<string, string> = {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',

View File

@@ -4,7 +4,7 @@ import type { Project, TrashedProject } from '@/lib/electron';
import type {
Feature as BaseFeature,
FeatureImagePath,
AgentModel,
ModelAlias,
PlanningMode,
AIProfile,
CursorModelId,
@@ -13,8 +13,8 @@ import type {
} from '@automaker/types';
import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
// Re-export ThemeMode for convenience
export type { ThemeMode };
// Re-export types for convenience
export type { ThemeMode, ModelAlias };
export type ViewMode =
| 'welcome'
@@ -477,10 +477,10 @@ export interface AppState {
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Enhancement Model Settings
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
validationModel: ModelAlias; // Model used for GitHub issue validation (default: opus)
// Phase Model Settings - per-phase AI model configuration
phaseModels: PhaseModelConfig;
@@ -760,13 +760,13 @@ export interface AppActions {
setMuteDoneSound: (muted: boolean) => void;
// Enhancement Model actions
setEnhancementModel: (model: AgentModel) => void;
setEnhancementModel: (model: ModelAlias) => void;
// Validation Model actions
setValidationModel: (model: AgentModel) => void;
setValidationModel: (model: ModelAlias) => void;
// Phase Model actions
setPhaseModel: (phase: PhaseModelKey, model: AgentModel | CursorModelId) => void;
setPhaseModel: (phase: PhaseModelKey, model: ModelAlias | CursorModelId) => void;
setPhaseModels: (models: Partial<PhaseModelConfig>) => void;
resetPhaseModels: () => void;

View File

@@ -8,7 +8,14 @@
* - Handles multiple model sources with priority
*/
import { CLAUDE_MODEL_MAP, CURSOR_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types';
import {
CLAUDE_MODEL_MAP,
CURSOR_MODEL_MAP,
DEFAULT_MODELS,
PROVIDER_PREFIXES,
isCursorModel,
stripProviderPrefix,
} from '@automaker/types';
/**
* Resolve a model key/alias to a full model string
@@ -33,8 +40,8 @@ export function resolveModelString(
// Cursor model with explicit prefix (e.g., "cursor-composer-1") - pass through unchanged
// CursorProvider will strip the prefix when calling the CLI
if (modelKey.startsWith('cursor-')) {
const cursorModelId = modelKey.replace('cursor-', '');
if (modelKey.startsWith(PROVIDER_PREFIXES.cursor)) {
const cursorModelId = stripProviderPrefix(modelKey);
// Verify it's a valid Cursor model
if (cursorModelId in CURSOR_MODEL_MAP) {
console.log(
@@ -50,7 +57,7 @@ export function resolveModelString(
// Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o")
if (modelKey in CURSOR_MODEL_MAP) {
// Return with cursor- prefix so provider routing works correctly
const prefixedModel = `cursor-${modelKey}`;
const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`;
console.log(
`[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"`
);

View File

@@ -33,7 +33,7 @@ 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, DEFAULT_MODELS, type ModelAlias } from './model.js';
// Event types
export type { EventType, EventCallback } from './event.js';
@@ -114,3 +114,15 @@ export type {
// Cursor types
export * from './cursor-models.js';
export * from './cursor-cli.js';
// Provider utilities
export {
PROVIDER_PREFIXES,
isCursorModel,
isClaudeModel,
getModelProvider,
stripProviderPrefix,
addProviderPrefix,
getBareModelId,
normalizeModelString,
} from './provider-utils.js';

View File

@@ -4,7 +4,7 @@
* Types for validating GitHub issues against the codebase using Claude SDK.
*/
import type { AgentModel } from './model.js';
import type { ModelAlias } from './model.js';
/**
* Verdict from issue validation
@@ -102,7 +102,7 @@ export type IssueValidationEvent =
result: IssueValidationResult;
projectPath: string;
/** Model used for validation (opus, sonnet, haiku) */
model: AgentModel;
model: ModelAlias;
}
| {
type: 'issue_validation_error';
@@ -127,7 +127,7 @@ export interface StoredValidation {
/** ISO timestamp when validation was performed */
validatedAt: string;
/** Model used for validation (opus, sonnet, haiku) */
model: AgentModel;
model: ModelAlias;
/** The validation result */
result: IssueValidationResult;
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */

View File

@@ -5,7 +5,7 @@
* and thinking levels used throughout the application UI.
*/
import type { AgentModel, ThinkingLevel, ModelProvider } from './settings.js';
import type { ModelAlias, ThinkingLevel, ModelProvider } from './settings.js';
import type { CursorModelId } from './cursor-models.js';
/**
@@ -13,7 +13,7 @@ import type { CursorModelId } from './cursor-models.js';
*/
export interface ModelOption {
/** Model identifier (supports both Claude and Cursor models) */
id: AgentModel | CursorModelId;
id: ModelAlias | CursorModelId;
/** Display name shown to user */
label: string;
/** Descriptive text explaining model capabilities */
@@ -102,7 +102,7 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
* getModelDisplayName("claude-opus-4-20250514"); // "claude-opus-4-20250514"
* ```
*/
export function getModelDisplayName(model: AgentModel | string): string {
export function getModelDisplayName(model: ModelAlias | string): string {
const displayNames: Record<string, string> = {
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',

View File

@@ -16,9 +16,3 @@ export const DEFAULT_MODELS = {
} as const;
export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP;
/**
* AgentModel - Alias for ModelAlias for backward compatibility
* Represents available Claude models: "opus" | "sonnet" | "haiku"
*/
export type AgentModel = ModelAlias;

View File

@@ -0,0 +1,140 @@
/**
* Provider utility functions
*
* Centralized utilities for determining model providers.
* When adding new providers, update these functions instead of
* scattering .startsWith() checks throughout the codebase.
*/
import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js';
import { CLAUDE_MODEL_MAP } from './model.js';
/** Provider prefix constants */
export const PROVIDER_PREFIXES = {
cursor: 'cursor-',
// Add new provider prefixes here
} as const;
/**
* Check if a model string represents a Cursor model
*
* @param model - Model string to check (e.g., "cursor-composer-1" or "composer-1")
* @returns true if the model is a Cursor model
*/
export function isCursorModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check for explicit cursor- prefix
if (model.startsWith(PROVIDER_PREFIXES.cursor)) {
return true;
}
// Check if it's a bare Cursor model ID
return model in CURSOR_MODEL_MAP;
}
/**
* Check if a model string represents a Claude model
*
* @param model - Model string to check (e.g., "sonnet", "opus", "claude-sonnet-4-20250514")
* @returns true if the model is a Claude model
*/
export function isClaudeModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Check if it's a Claude model alias (haiku, sonnet, opus)
if (model in CLAUDE_MODEL_MAP) {
return true;
}
// Check if it contains 'claude-' in the string (full model ID)
return model.includes('claude-');
}
/**
* Get the provider for a model string
*
* @param model - Model string to check
* @returns The provider type, defaults to 'claude' for unknown models
*/
export function getModelProvider(model: string | undefined | null): ModelProvider {
if (isCursorModel(model)) {
return 'cursor';
}
return 'claude';
}
/**
* Strip the provider prefix from a model string
*
* @param model - Model string that may have a provider prefix
* @returns Model string without provider prefix
*
* @example
* stripProviderPrefix('cursor-composer-1') // 'composer-1'
* stripProviderPrefix('sonnet') // 'sonnet'
*/
export function stripProviderPrefix(model: string): string {
if (!model || typeof model !== 'string') return model;
for (const prefix of Object.values(PROVIDER_PREFIXES)) {
if (model.startsWith(prefix)) {
return model.slice(prefix.length);
}
}
return model;
}
/**
* Add the provider prefix to a model string if not already present
*
* @param model - Bare model ID
* @param provider - Provider to add prefix for
* @returns Model string with provider prefix
*
* @example
* addProviderPrefix('composer-1', 'cursor') // 'cursor-composer-1'
* addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change)
* addProviderPrefix('sonnet', 'claude') // 'sonnet' (Claude doesn't use prefix)
*/
export function addProviderPrefix(model: string, provider: ModelProvider): string {
if (!model || typeof model !== 'string') return model;
if (provider === 'cursor') {
if (!model.startsWith(PROVIDER_PREFIXES.cursor)) {
return `${PROVIDER_PREFIXES.cursor}${model}`;
}
}
// Claude models don't use prefixes
return model;
}
/**
* Get the bare model ID from a model string (without provider prefix)
*
* @param model - Model string that may have a provider prefix
* @returns The bare model ID
*/
export function getBareModelId(model: string): string {
return stripProviderPrefix(model);
}
/**
* Normalize a model string to its canonical form
* - For Cursor: adds cursor- prefix if missing
* - For Claude: returns as-is
*
* @param model - Model string to normalize
* @returns Normalized model string
*/
export function normalizeModelString(model: string | undefined | null): string {
if (!model || typeof model !== 'string') return 'sonnet'; // Default
// If it's a Cursor model without prefix, add the prefix
if (model in CURSOR_MODEL_MAP && !model.startsWith(PROVIDER_PREFIXES.cursor)) {
return `${PROVIDER_PREFIXES.cursor}${model}`;
}
return model;
}

View File

@@ -6,12 +6,12 @@
* (for file I/O via SettingsService) and the UI (for state management and sync).
*/
import type { AgentModel } from './model.js';
import type { ModelAlias } from './model.js';
import type { CursorModelId } from './cursor-models.js';
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
// Re-export AgentModel for convenience
export type { AgentModel };
// Re-export ModelAlias for convenience
export type { ModelAlias };
/**
* ThemeMode - Available color themes for the UI
@@ -82,25 +82,25 @@ export type ModelProvider = 'claude' | 'cursor';
export interface PhaseModelConfig {
// Quick tasks - recommend fast/cheap models (Haiku, Cursor auto)
/** Model for enhancing feature names and descriptions */
enhancementModel: AgentModel | CursorModelId;
enhancementModel: ModelAlias | CursorModelId;
/** Model for generating file context descriptions */
fileDescriptionModel: AgentModel | CursorModelId;
fileDescriptionModel: ModelAlias | CursorModelId;
/** Model for analyzing and describing context images */
imageDescriptionModel: AgentModel | CursorModelId;
imageDescriptionModel: ModelAlias | CursorModelId;
// Validation tasks - recommend smart models (Sonnet, Opus)
/** Model for validating and improving GitHub issues */
validationModel: AgentModel | CursorModelId;
validationModel: ModelAlias | CursorModelId;
// Generation tasks - recommend powerful models (Opus, Sonnet)
/** Model for generating full application specifications */
specGenerationModel: AgentModel | CursorModelId;
specGenerationModel: ModelAlias | CursorModelId;
/** Model for creating features from specifications */
featureGenerationModel: AgentModel | CursorModelId;
featureGenerationModel: ModelAlias | CursorModelId;
/** Model for reorganizing and prioritizing backlog */
backlogPlanningModel: AgentModel | CursorModelId;
backlogPlanningModel: ModelAlias | CursorModelId;
/** Model for analyzing project structure */
projectAnalysisModel: AgentModel | CursorModelId;
projectAnalysisModel: ModelAlias | CursorModelId;
}
/** Keys of PhaseModelConfig for type-safe access */
@@ -196,7 +196,7 @@ export interface AIProfile {
// Claude-specific settings
/** Which Claude model to use (opus, sonnet, haiku) - only for Claude provider */
model?: AgentModel;
model?: ModelAlias;
/** Extended thinking level for reasoning-based tasks - only for Claude provider */
thinkingLevel?: ThinkingLevel;
@@ -338,9 +338,9 @@ export interface GlobalSettings {
// Legacy AI Model Selection (deprecated - use phaseModels instead)
/** @deprecated Use phaseModels.enhancementModel instead */
enhancementModel: AgentModel;
enhancementModel: ModelAlias;
/** @deprecated Use phaseModels.validationModel instead */
validationModel: AgentModel;
validationModel: ModelAlias;
// Cursor CLI Settings (global)
/** Which Cursor models are available in feature modal (empty = all) */

View File

@@ -17,8 +17,7 @@ Allow users to configure which AI provider/model to use for each distinct phase
These fields already exist in `GlobalSettings` but are not wired up:
```typescript
// libs/types/src/settings.ts - Line ~50
enhancementModel: AgentModel; // Currently ignored, hardcoded to 'sonnet'
// libs/types/src/settenhancementModel: AgentModel; // Currently ignored, hardcoded to 'sonnet'
validationModel: AgentModel; // Currently ignored, hardcoded to 'opus'
```
@@ -27,8 +26,8 @@ validationModel: AgentModel; // Currently ignored, hardcoded to 'opus'
| Phase | Location | Current Model | Priority |
| ------------------- | -------------------------------- | ------------------ | -------- |
| Feature Execution | `auto-mode-service.ts` | Per-feature | ✅ Done |
| Enhancement | `enhance.ts` | Hardcoded `sonnet` | P1 |
| GitHub Validation | `validate-issue.ts` | Hardcoded `opus` | P1 |
| Enhancement | `enhance.ts` | Hardcoded `sonnet` | ✅ Done |
| GitHub Validation | `validate-issue.ts` | Hardcoded `opus` | ✅ Done |
| File Description | `describe-file.ts` | Hardcoded `haiku` | P2 |
| Image Description | `describe-image.ts` | Hardcoded `haiku` | P2 |
| App Spec Generation | `generate-spec.ts` | SDK default | P2 |
@@ -372,8 +371,8 @@ Phase 8: Quick Model Override Component <- Right after UI for easier testing
Phase 9: Integration Points for Override <- Wire override to each feature
Then wire routes (testing both global + override together):
Phase 3: Enhancement Route
Phase 4: Validation Route
Phase 3: Enhancement Route - Done
Phase 4: Validation Route - Broken validation parsing ! need urgent fix to properly validate cursor cli output
Phase 5: Context Routes (file/image description)
Phase 6: Generation Routes (spec, features)
Phase 7: Remaining Routes (backlog, analysis)
@@ -676,6 +675,34 @@ Add override trigger in header:
Add override for validation model:
for this feature
- Clear override returns to profile default
### 9.2 Kanban Card
**File**: `apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx`
Add small gear icon next to "Implement" button:
- Quick model change before running
- Doesn't persist to feature (one-time override)
### 9.3 Enhancement Dialog
**File**: `apps/ui/src/components/views/board-view/components/enhance-dialog.tsx`
Add override trigger in header:
- Default from global settings
- Override for this enhancement only
### 9.4 GitHub Import
**File**: `apps/ui/src/components/views/github-view/`
Add override for validation model:
- Default from global settings
- Override for this import session