feat: add reasoning effort support for Codex models

- Add ReasoningEffortSelector component for UI selection
- Integrate reasoning effort in feature creation/editing dialogs
- Add reasoning effort support to phase model selector
- Update agent service and board actions to handle reasoning effort
- Add reasoning effort fields to feature and settings types
- Update model selector and agent info panel with reasoning effort display
- Enhance agent context parser for reasoning effort processing

Reasoning effort allows fine-tuned control over Codex model reasoning
capabilities, providing options from 'none' to 'xhigh' for different
task complexity requirements.
This commit is contained in:
DhanushSantosh
2026-01-08 20:43:36 +05:30
parent 8a9715adef
commit 4dcf54146c
13 changed files with 361 additions and 59 deletions

View File

@@ -6,7 +6,7 @@
import path from 'path'; import path from 'path';
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types'; import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { import {
readImageAsBase64, readImageAsBase64,
buildPromptWithImages, buildPromptWithImages,
@@ -56,6 +56,7 @@ interface Session {
workingDirectory: string; workingDirectory: string;
model?: string; model?: string;
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
sdkSessionId?: string; // Claude SDK session ID for conversation continuity sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
} }
@@ -145,6 +146,7 @@ export class AgentService {
imagePaths, imagePaths,
model, model,
thinkingLevel, thinkingLevel,
reasoningEffort,
}: { }: {
sessionId: string; sessionId: string;
message: string; message: string;
@@ -152,6 +154,7 @@ export class AgentService {
imagePaths?: string[]; imagePaths?: string[];
model?: string; model?: string;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
}) { }) {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
@@ -164,7 +167,7 @@ export class AgentService {
throw new Error('Agent is already processing a message'); throw new Error('Agent is already processing a message');
} }
// Update session model and thinking level if provided // Update session model, thinking level, and reasoning effort if provided
if (model) { if (model) {
session.model = model; session.model = model;
await this.updateSession(sessionId, { model }); await this.updateSession(sessionId, { model });
@@ -172,6 +175,9 @@ export class AgentService {
if (thinkingLevel !== undefined) { if (thinkingLevel !== undefined) {
session.thinkingLevel = thinkingLevel; session.thinkingLevel = thinkingLevel;
} }
if (reasoningEffort !== undefined) {
session.reasoningEffort = reasoningEffort;
}
// Validate vision support before processing images // Validate vision support before processing images
const effectiveModel = model || session.model; const effectiveModel = model || session.model;
@@ -265,8 +271,9 @@ export class AgentService {
: baseSystemPrompt; : baseSystemPrompt;
// Build SDK options using centralized configuration // Build SDK options using centralized configuration
// Use thinking level from request, or fall back to session's stored thinking level // Use thinking level and reasoning effort from request, or fall back to session's stored values
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
const sdkOptions = createChatOptions({ const sdkOptions = createChatOptions({
cwd: effectiveWorkDir, cwd: effectiveWorkDir,
model: model, model: model,
@@ -299,6 +306,8 @@ export class AgentService {
settingSources: sdkOptions.settingSources, settingSources: sdkOptions.settingSources,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
}; };
// Build prompt content with images // Build prompt content with images

View File

@@ -1,6 +1,8 @@
// @ts-nocheck // @ts-nocheck
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store'; import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { getProviderFromModel } from '@/lib/utils';
import { import {
AgentTaskInfo, AgentTaskInfo,
parseAgentContext, parseAgentContext,
@@ -37,6 +39,22 @@ function formatThinkingLevel(level: ThinkingLevel | undefined): string {
return labels[level]; return labels[level];
} }
/**
* Formats reasoning effort for compact display
*/
function formatReasoningEffort(effort: ReasoningEffort | undefined): string {
if (!effort || effort === 'none') return '';
const labels: Record<ReasoningEffort, string> = {
none: '',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
};
return labels[effort];
}
interface AgentInfoPanelProps { interface AgentInfoPanelProps {
feature: Feature; feature: Feature;
contextContent?: string; contextContent?: string;
@@ -106,6 +124,10 @@ export function AgentInfoPanel({
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]); }, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards // Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === 'backlog') { if (showAgentInfo && feature.status === 'backlog') {
const provider = getProviderFromModel(feature.model);
const isCodex = provider === 'codex';
const isClaude = provider === 'claude';
return ( return (
<div className="mb-3 space-y-2 overflow-hidden"> <div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap"> <div className="flex items-center gap-2 text-[11px] flex-wrap">
@@ -116,7 +138,7 @@ export function AgentInfoPanel({
})()} })()}
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span> <span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div> </div>
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? ( {isClaude && feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400"> <div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" /> <Brain className="w-3 h-3" />
<span className="font-medium"> <span className="font-medium">
@@ -124,6 +146,14 @@ export function AgentInfoPanel({
</span> </span>
</div> </div>
) : null} ) : null}
{isCodex && feature.reasoningEffort && feature.reasoningEffort !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatReasoningEffort(feature.reasoningEffort as ReasoningEffort)}
</span>
</div>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -41,9 +41,12 @@ import {
PlanningMode, PlanningMode,
Feature, Feature,
} from '@/store/app-store'; } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { codexModelHasThinking, supportsReasoningEffort } from '@automaker/types';
import { import {
ModelSelector, ModelSelector,
ThinkingLevelSelector, ThinkingLevelSelector,
ReasoningEffortSelector,
ProfileQuickSelect, ProfileQuickSelect,
TestingTabContent, TestingTabContent,
PrioritySelector, PrioritySelector,
@@ -78,6 +81,7 @@ type FeatureData = {
skipTests: boolean; skipTests: boolean;
model: AgentModel; model: AgentModel;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
branchName: string; // Can be empty string to use current branch branchName: string; // Can be empty string to use current branch
priority: number; priority: number;
planningMode: PlanningMode; planningMode: PlanningMode;
@@ -134,6 +138,7 @@ export function AddFeatureDialog({
skipTests: false, skipTests: false,
model: 'opus' as ModelAlias, model: 'opus' as ModelAlias,
thinkingLevel: 'none' as ThinkingLevel, thinkingLevel: 'none' as ThinkingLevel,
reasoningEffort: 'none' as ReasoningEffort,
branchName: '', branchName: '',
priority: 2 as number, // Default to medium priority priority: 2 as number, // Default to medium priority
}); });
@@ -220,6 +225,9 @@ export function AddFeatureDialog({
const normalizedThinking = modelSupportsThinking(selectedModel) const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel ? newFeature.thinkingLevel
: 'none'; : 'none';
const normalizedReasoning = supportsReasoningEffort(selectedModel)
? newFeature.reasoningEffort
: 'none';
// Use current branch if toggle is on // Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it // If currentBranch is provided (non-primary worktree), use it
@@ -260,6 +268,7 @@ export function AddFeatureDialog({
skipTests: newFeature.skipTests, skipTests: newFeature.skipTests,
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
branchName: finalBranchName, branchName: finalBranchName,
priority: newFeature.priority, priority: newFeature.priority,
planningMode, planningMode,
@@ -281,6 +290,7 @@ export function AddFeatureDialog({
model: 'opus', model: 'opus',
priority: 2, priority: 2,
thinkingLevel: 'none', thinkingLevel: 'none',
reasoningEffort: 'none',
branchName: '', branchName: '',
}); });
setUseCurrentBranch(true); setUseCurrentBranch(true);
@@ -394,6 +404,9 @@ export function AddFeatureDialog({
const newModelAllowsThinking = const newModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet'); !isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet');
// Codex models that support reasoning effort - show reasoning selector
const newModelAllowsReasoning = supportsReasoningEffort(newFeature.model || '');
return ( return (
<Dialog open={open} onOpenChange={handleDialogClose}> <Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent <DialogContent
@@ -619,6 +632,14 @@ export function AddFeatureDialog({
} }
/> />
)} )}
{newModelAllowsReasoning && (
<ReasoningEffortSelector
selectedEffort={newFeature.reasoningEffort}
onEffortSelect={(effort) =>
setNewFeature({ ...newFeature, reasoningEffort: effort })
}
/>
)}
</> </>
)} )}
</TabsContent> </TabsContent>

View File

@@ -41,9 +41,11 @@ import {
useAppStore, useAppStore,
PlanningMode, PlanningMode,
} from '@/store/app-store'; } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { import {
ModelSelector, ModelSelector,
ThinkingLevelSelector, ThinkingLevelSelector,
ReasoningEffortSelector,
ProfileQuickSelect, ProfileQuickSelect,
TestingTabContent, TestingTabContent,
PrioritySelector, PrioritySelector,
@@ -60,7 +62,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { DescriptionHistoryEntry } from '@automaker/types'; import type { DescriptionHistoryEntry } from '@automaker/types';
import { DependencyTreeDialog } from './dependency-tree-dialog'; import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types'; import { isCursorModel, PROVIDER_PREFIXES, supportsReasoningEffort } from '@automaker/types';
const logger = createLogger('EditFeatureDialog'); const logger = createLogger('EditFeatureDialog');
@@ -76,6 +78,7 @@ interface EditFeatureDialogProps {
skipTests: boolean; skipTests: boolean;
model: ModelAlias; model: ModelAlias;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
imagePaths: DescriptionImagePath[]; imagePaths: DescriptionImagePath[];
textFilePaths: DescriptionTextFilePath[]; textFilePaths: DescriptionTextFilePath[];
branchName: string; // Can be empty string to use current branch branchName: string; // Can be empty string to use current branch
@@ -180,6 +183,9 @@ export function EditFeatureDialog({
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel) const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none') ? (editingFeature.thinkingLevel ?? 'none')
: 'none'; : 'none';
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
? (editingFeature.reasoningEffort ?? 'none')
: 'none';
// Use current branch if toggle is on // Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it // If currentBranch is provided (non-primary worktree), use it
@@ -195,6 +201,7 @@ export function EditFeatureDialog({
skipTests: editingFeature.skipTests ?? false, skipTests: editingFeature.skipTests ?? false,
model: selectedModel, model: selectedModel,
thinkingLevel: normalizedThinking, thinkingLevel: normalizedThinking,
reasoningEffort: normalizedReasoning,
imagePaths: editingFeature.imagePaths ?? [], imagePaths: editingFeature.imagePaths ?? [],
textFilePaths: editingFeature.textFilePaths ?? [], textFilePaths: editingFeature.textFilePaths ?? [],
branchName: finalBranchName, branchName: finalBranchName,
@@ -233,15 +240,17 @@ export function EditFeatureDialog({
if (!editingFeature) return; if (!editingFeature) return;
// For Cursor models, thinking is handled by the model itself // For Cursor models, thinking is handled by the model itself
// For Claude models, check if it supports extended thinking // For Claude models, check if it supports extended thinking
// For Codex models, use reasoning effort instead
const isCursor = isCursorModel(model); const isCursor = isCursorModel(model);
const supportsThinking = modelSupportsThinking(model);
const supportsReasoning = supportsReasoningEffort(model);
setEditingFeature({ setEditingFeature({
...editingFeature, ...editingFeature,
model: model as ModelAlias, model: model as ModelAlias,
thinkingLevel: isCursor thinkingLevel:
? 'none' isCursor || !supportsThinking ? 'none' : (editingFeature.thinkingLevel ?? 'none'),
: modelSupportsThinking(model) reasoningEffort: !supportsReasoning ? 'none' : (editingFeature.reasoningEffort ?? 'none'),
? editingFeature.thinkingLevel
: 'none',
}); });
}; };
@@ -312,6 +321,9 @@ export function EditFeatureDialog({
const editModelAllowsThinking = const editModelAllowsThinking =
!isCurrentModelCursor && modelSupportsThinking(editingFeature?.model); !isCurrentModelCursor && modelSupportsThinking(editingFeature?.model);
// Codex models that support reasoning effort - show reasoning selector
const editModelAllowsReasoning = supportsReasoningEffort(editingFeature?.model || '');
if (!editingFeature) { if (!editingFeature) {
return null; return null;
} }
@@ -634,6 +646,18 @@ export function EditFeatureDialog({
testIdPrefix="edit-thinking-level" testIdPrefix="edit-thinking-level"
/> />
)} )}
{editModelAllowsReasoning && (
<ReasoningEffortSelector
selectedEffort={editingFeature.reasoningEffort ?? 'none'}
onEffortSelect={(effort) =>
setEditingFeature({
...editingFeature,
reasoningEffort: effort,
})
}
testIdPrefix="edit-reasoning-effort"
/>
)}
</> </>
)} )}
</TabsContent> </TabsContent>

View File

@@ -8,6 +8,7 @@ import {
PlanningMode, PlanningMode,
useAppStore, useAppStore,
} from '@/store/app-store'; } from '@/store/app-store';
import type { ReasoningEffort } from '@automaker/types';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone'; import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -222,6 +223,7 @@ export function useBoardActions({
skipTests: boolean; skipTests: boolean;
model: ModelAlias; model: ModelAlias;
thinkingLevel: ThinkingLevel; thinkingLevel: ThinkingLevel;
reasoningEffort: ReasoningEffort;
imagePaths: DescriptionImagePath[]; imagePaths: DescriptionImagePath[];
branchName: string; branchName: string;
priority: number; priority: number;

View File

@@ -1,6 +1,7 @@
export * from './model-constants'; export * from './model-constants';
export * from './model-selector'; export * from './model-selector';
export * from './thinking-level-selector'; export * from './thinking-level-selector';
export * from './reasoning-effort-selector';
export * from './profile-quick-select'; export * from './profile-quick-select';
export * from './profile-select'; export * from './profile-select';
export * from './testing-tab-content'; export * from './testing-tab-content';

View File

@@ -45,8 +45,8 @@ export function ModelSelector({
// Switch to Cursor's default model (from global settings) // Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
} else if (provider === 'codex' && selectedProvider !== 'codex') { } else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (gpt-5.2) // Switch to Codex's default model (gpt-5.2-codex)
onModelSelect('gpt-5.2'); onModelSelect('gpt-5.2-codex');
} else if (provider === 'claude' && selectedProvider !== 'claude') { } else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model // Switch to Claude's default model
onModelSelect('sonnet'); onModelSelect('sonnet');

View File

@@ -0,0 +1,47 @@
import { Label } from '@/components/ui/label';
import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ReasoningEffort } from '@automaker/types';
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS } from './model-constants';
interface ReasoningEffortSelectorProps {
selectedEffort: ReasoningEffort;
onEffortSelect: (effort: ReasoningEffort) => void;
testIdPrefix?: string;
}
export function ReasoningEffortSelector({
selectedEffort,
onEffortSelect,
testIdPrefix = 'reasoning-effort',
}: ReasoningEffortSelectorProps) {
return (
<div className="space-y-2 pt-2 border-t border-border">
<Label className="flex items-center gap-2 text-sm">
<Brain className="w-3.5 h-3.5 text-muted-foreground" />
Reasoning Effort
</Label>
<div className="flex gap-2 flex-wrap">
{REASONING_EFFORT_LEVELS.map((effort) => (
<button
key={effort}
type="button"
onClick={() => onEffortSelect(effort)}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors min-w-[60px]',
selectedEffort === effort
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-input'
)}
data-testid={`${testIdPrefix}-${effort}`}
>
{REASONING_EFFORT_LABELS[effort]}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher efforts give more reasoning tokens for complex problems.
</p>
</div>
);
}

View File

@@ -4,9 +4,11 @@ import { useAppStore } from '@/store/app-store';
import type { import type {
ModelAlias, ModelAlias,
CursorModelId, CursorModelId,
CodexModelId,
GroupedModel, GroupedModel,
PhaseModelEntry, PhaseModelEntry,
ThinkingLevel, ThinkingLevel,
ReasoningEffort,
} from '@automaker/types'; } from '@automaker/types';
import { import {
stripProviderPrefix, stripProviderPrefix,
@@ -15,6 +17,7 @@ import {
isGroupSelected, isGroupSelected,
getSelectedVariant, getSelectedVariant,
isCursorModel, isCursorModel,
codexModelHasThinking,
} from '@automaker/types'; } from '@automaker/types';
import { import {
CLAUDE_MODELS, CLAUDE_MODELS,
@@ -22,6 +25,8 @@ import {
CODEX_MODELS, CODEX_MODELS,
THINKING_LEVELS, THINKING_LEVELS,
THINKING_LEVEL_LABELS, THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
} from '@/components/views/board-view/shared/model-constants'; } from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -69,14 +74,17 @@ export function PhaseModelSelector({
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null); const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null); const [expandedClaudeModel, setExpandedClaudeModel] = React.useState<ModelAlias | null>(null);
const [expandedCodexModel, setExpandedCodexModel] = React.useState<CodexModelId | null>(null);
const commandListRef = React.useRef<HTMLDivElement>(null); const commandListRef = React.useRef<HTMLDivElement>(null);
const expandedTriggerRef = React.useRef<HTMLDivElement>(null); const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null); const expandedClaudeTriggerRef = React.useRef<HTMLDivElement>(null);
const expandedCodexTriggerRef = React.useRef<HTMLDivElement>(null);
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore(); const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
// Extract model and thinking level from value // Extract model and thinking/reasoning levels from value
const selectedModel = value.model; const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedThinkingLevel = value.thinkingLevel || 'none';
const selectedReasoningEffort = value.reasoningEffort || 'none';
// Close expanded group when trigger scrolls out of view // Close expanded group when trigger scrolls out of view
React.useEffect(() => { React.useEffect(() => {
@@ -124,6 +132,29 @@ export function PhaseModelSelector({
return () => observer.disconnect(); return () => observer.disconnect();
}, [expandedClaudeModel]); }, [expandedClaudeModel]);
// Close expanded Codex model popover when trigger scrolls out of view
React.useEffect(() => {
const triggerElement = expandedCodexTriggerRef.current;
const listElement = commandListRef.current;
if (!triggerElement || !listElement || !expandedCodexModel) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry.isIntersecting) {
setExpandedCodexModel(null);
}
},
{
root: listElement,
threshold: 0.1,
}
);
observer.observe(triggerElement);
return () => observer.disconnect();
}, [expandedCodexModel]);
// Filter Cursor models to only show enabled ones // Filter Cursor models to only show enabled ones
const availableCursorModels = CURSOR_MODELS.filter((model) => { const availableCursorModels = CURSOR_MODELS.filter((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId; const cursorId = stripProviderPrefix(model.id) as CursorModelId;
@@ -241,55 +272,183 @@ export function PhaseModelSelector({
return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels }; return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels };
}, [favoriteModels, availableCursorModels]); }, [favoriteModels, availableCursorModels]);
// Render Codex model item (no thinking level needed) // Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => { const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => {
const isSelected = selectedModel === model.id; const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id); const isFavorite = favoriteModels.includes(model.id);
const hasReasoning = codexModelHasThinking(model.id as CodexModelId);
const isExpanded = expandedCodexModel === model.id;
const currentReasoning = isSelected ? selectedReasoningEffort : 'none';
// If model doesn't support reasoning, render as simple selector (like Cursor models)
if (!hasReasoning) {
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as CodexModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenAIIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
}
// Model supports reasoning - show popover with reasoning effort options
return ( return (
<CommandItem <CommandItem
key={model.id} key={model.id}
value={model.label} value={model.label}
onSelect={() => { onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
onChange({ model: model.id }); className="p-0 data-[selected=true]:bg-transparent"
setOpen(false);
}}
className="group flex items-center justify-between py-2"
> >
<div className="flex items-center gap-3 overflow-hidden"> <Popover
<OpenAIIcon open={isExpanded}
className={cn( onOpenChange={(isOpen) => {
'h-4 w-4 shrink-0', if (!isOpen) {
isSelected ? 'text-primary' : 'text-muted-foreground' setExpandedCodexModel(null);
)} }
/> }}
<div className="flex flex-col truncate"> >
<span className={cn('truncate font-medium', isSelected && 'text-primary')}> <PopoverTrigger asChild>
{model.label} <div
</span> ref={isExpanded ? expandedCodexTriggerRef : undefined}
<span className="truncate text-xs text-muted-foreground">{model.description}</span> className={cn(
</div> 'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
</div> 'hover:bg-accent',
isExpanded && 'bg-accent'
)}
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenAIIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentReasoning !== 'none'
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2"> <div className="flex items-center gap-1 ml-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn( className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0', 'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite isFavorite
? 'text-yellow-500 opacity-100' ? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground' : 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleFavoriteModel(model.id); toggleFavoriteModel(model.id);
}} }}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</div>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
> >
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} /> <div className="space-y-1">
</Button> <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />} Reasoning Effort
</div> </div>
{REASONING_EFFORT_LEVELS.map((effort) => (
<button
key={effort}
onClick={() => {
onChange({
model: model.id as CodexModelId,
reasoningEffort: effort,
});
setExpandedCodexModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium">{REASONING_EFFORT_LABELS[effort]}</span>
<span className="text-xs text-muted-foreground">
{effort === 'none' && 'No reasoning capability'}
{effort === 'minimal' && 'Minimal reasoning'}
{effort === 'low' && 'Light reasoning'}
{effort === 'medium' && 'Moderate reasoning'}
{effort === 'high' && 'Deep reasoning'}
{effort === 'xhigh' && 'Maximum reasoning'}
</span>
</div>
{isSelected && currentReasoning === effort && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
</CommandItem> </CommandItem>
); );
}; };

View File

@@ -38,12 +38,13 @@ export function formatModelName(model: string): string {
if (model.includes('sonnet')) return 'Sonnet 4.5'; if (model.includes('sonnet')) return 'Sonnet 4.5';
if (model.includes('haiku')) return 'Haiku 4.5'; if (model.includes('haiku')) return 'Haiku 4.5';
// Codex/GPT models // Codex/GPT models - specific formatting
if (model === 'gpt-5.2-codex') return 'GPT-5.2 Codex';
if (model === 'gpt-5.2') return 'GPT-5.2'; if (model === 'gpt-5.2') return 'GPT-5.2';
if (model === 'gpt-5.1-codex-max') return 'GPT-5.1 Max'; if (model === 'gpt-5.1-codex-max') return 'GPT-5.1 Max';
if (model === 'gpt-5.1-codex') return 'GPT-5.1 Codex';
if (model === 'gpt-5.1-codex-mini') return 'GPT-5.1 Mini'; if (model === 'gpt-5.1-codex-mini') return 'GPT-5.1 Mini';
if (model === 'gpt-5.1') return 'GPT-5.1'; if (model === 'gpt-5.1') return 'GPT-5.1';
// Generic fallbacks for other GPT models
if (model.startsWith('gpt-')) return model.toUpperCase(); if (model.startsWith('gpt-')) return model.toUpperCase();
if (model.match(/^o\d/)) return model.toUpperCase(); // o1, o3, etc. if (model.match(/^o\d/)) return model.toUpperCase(); // o1, o3, etc.

View File

@@ -9,13 +9,14 @@ export function cn(...inputs: ClassValue[]) {
/** /**
* Determine if the current model supports extended thinking controls * Determine if the current model supports extended thinking controls
* Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort"
*/ */
export function modelSupportsThinking(_model?: ModelAlias | string): boolean { export function modelSupportsThinking(_model?: ModelAlias | string): boolean {
if (!_model) return true; if (!_model) return true;
// Check if it's a Codex model with thinking support // Codex models don't support Claude thinking levels - they use reasoning effort instead
if (_model.startsWith('gpt-') && _model in CODEX_MODEL_CONFIG_MAP) { if (_model.startsWith('gpt-') && _model in CODEX_MODEL_CONFIG_MAP) {
return codexModelHasThinking(_model as any); return false;
} }
// All Claude models support thinking // All Claude models support thinking

View File

@@ -3,6 +3,7 @@
*/ */
import type { PlanningMode, ThinkingLevel } from './settings.js'; import type { PlanningMode, ThinkingLevel } from './settings.js';
import type { ReasoningEffort } from './provider.js';
/** /**
* A single entry in the description history * A single entry in the description history
@@ -49,6 +50,7 @@ export interface Feature {
branchName?: string; // Name of the feature branch (undefined = use current worktree) branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean; skipTests?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
planningMode?: PlanningMode; planningMode?: PlanningMode;
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
planSpec?: { planSpec?: {

View File

@@ -11,6 +11,7 @@ import type { CursorModelId } from './cursor-models.js';
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
import type { PromptCustomization } from './prompts.js'; import type { PromptCustomization } from './prompts.js';
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
import type { ReasoningEffort } from './provider.js';
// Re-export ModelAlias for convenience // Re-export ModelAlias for convenience
export type { ModelAlias }; export type { ModelAlias };
@@ -108,14 +109,18 @@ const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = [];
/** /**
* PhaseModelEntry - Configuration for a single phase model * PhaseModelEntry - Configuration for a single phase model
* *
* Encapsulates both the model selection and optional thinking level * Encapsulates the model selection and optional reasoning/thinking capabilities:
* for Claude models. Cursor models handle thinking internally. * - Claude models: Use thinkingLevel for extended thinking
* - Codex models: Use reasoningEffort for reasoning intensity
* - Cursor models: Handle thinking internally
*/ */
export interface PhaseModelEntry { export interface PhaseModelEntry {
/** The model to use (Claude alias or Cursor model ID) */ /** The model to use (Claude alias, Cursor model ID, or Codex model ID) */
model: ModelAlias | CursorModelId; model: ModelAlias | CursorModelId | CodexModelId;
/** Extended thinking level (only applies to Claude models, defaults to 'none') */ /** Extended thinking level (only applies to Claude models, defaults to 'none') */
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
/** Reasoning effort level (only applies to Codex models, defaults to 'none') */
reasoningEffort?: ReasoningEffort;
} }
/** /**