From d618abb07513e81e566f8b4878ee955852292c19 Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Tue, 30 Jun 2026 23:29:57 +0000 Subject: [PATCH] feat: add Claude and Codex effort controls --- server/claude-sdk.js | 53 ++++----- .../list/claude/claude-models.provider.ts | 74 ++++++++++++- .../list/codex/codex-models.provider.ts | 70 ++++++++++-- .../services/provider-models.service.ts | 2 +- server/openai-codex.js | 16 +-- server/shared/types.ts | 7 ++ .../chat/hooks/useChatComposerState.ts | 24 +++++ .../chat/hooks/useChatProviderState.ts | 101 +++++++++++++++++- src/components/chat/view/ChatInterface.tsx | 45 ++++++++ .../chat/view/subcomponents/ChatComposer.tsx | 89 ++++++++++++++- .../view/subcomponents/CommandResultModal.tsx | 1 - src/types/app.ts | 7 ++ 12 files changed, 447 insertions(+), 42 deletions(-) diff --git a/server/claude-sdk.js b/server/claude-sdk.js index a0a795c6..938e3de4 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -41,6 +41,16 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); +function resolveClaudeEffort(model, effort, modelsDefinition = CLAUDE_FALLBACK_MODELS) { + const selectedModel = modelsDefinition.OPTIONS + .find((option) => option.value === model) || null; + const allowedEfforts = selectedModel?.effort?.values + ?.map((value) => value.value) || []; + return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) + ? effort + : undefined; +} + function createRequestId() { if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -145,13 +155,8 @@ function matchesToolPermission(entry, toolName, input) { return false; } -/** - * Maps CLI options to SDK-compatible options format - * @param {Object} options - CLI options - * @returns {Object} SDK-compatible options - */ function mapCliOptionsToSDK(options = {}) { - const { sessionId, cwd, toolsSettings, permissionMode } = options; + const { sessionId, cwd, toolsSettings, permissionMode, effort } = options; const sdkOptions = {}; @@ -163,32 +168,26 @@ function mapCliOptionsToSDK(options = {}) { // which does not reliably follow npm's shell wrappers like cross-spawn does. sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH); - // Map working directory if (cwd) { sdkOptions.cwd = cwd; } - // Map permission mode if (permissionMode && permissionMode !== 'default') { sdkOptions.permissionMode = permissionMode; } - // Map tool settings const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false }; - // Handle tool permissions if (settings.skipPermissions && permissionMode !== 'plan') { - // When skipping permissions, use bypassPermissions mode sdkOptions.permissionMode = 'bypassPermissions'; } let allowedTools = [...(settings.allowedTools || [])]; - // Add plan mode default tools if (permissionMode === 'plan') { const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; for (const tool of planModeTools) { @@ -207,22 +206,24 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.disallowedTools = settings.disallowedTools || []; - // Map model (default to sonnet) - // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT; - // Model logged at query start below - // Map system prompt configuration + const resolvedEffort = resolveClaudeEffort( + sdkOptions.model, + effort, + options.effortModels || CLAUDE_FALLBACK_MODELS, + ); + if (resolvedEffort) { + sdkOptions.effort = resolvedEffort; + } + sdkOptions.systemPrompt = { type: 'preset', - preset: 'claude_code' // Required to use CLAUDE.md + preset: 'claude_code' }; - // Map setting sources for CLAUDE.md loading - // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories sdkOptions.settingSources = ['project', 'user', 'local']; - // Map resume session if (sessionId) { sdkOptions.resume = sessionId; } @@ -533,20 +534,24 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionId, options.model, ); + let effortModels = CLAUDE_FALLBACK_MODELS; + try { + effortModels = (await providerModelsService.getProviderModels('claude')).models; + } catch (error) { + console.warn('[Claude SDK] Unable to load provider models for effort validation:', error); + } - // Map CLI options to SDK format const sdkOptions = mapCliOptionsToSDK({ ...options, model: resolvedModel || options.model, + effortModels, }); - // Load MCP configuration const mcpServers = await loadMcpConfig(options.cwd); if (mcpServers) { sdkOptions.mcpServers = mcpServers; } - // Handle images - save to temp files and modify prompt const imageResult = await handleImages(command, options.images, options.cwd); const finalCommand = imageResult.modifiedCommand; tempImagePaths = imageResult.tempImagePaths; @@ -650,7 +655,7 @@ async function queryClaudeSDK(command, options = {}, ws) { return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; }; - // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it + // Query constructor reads this synchronously. const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index f6c4c0c6..2c80cc40 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -5,6 +5,7 @@ import type { IProviderModels } from '@/shared/interfaces.js'; import type { ProviderChangeActiveModelInput, ProviderCurrentActiveModel, + ProviderModelOption, ProviderModelsDefinition, ProviderSessionActiveModelChange, } from '@/shared/types.js'; @@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { { value: 'default', label: 'Default (recommended)', - description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok', + description: 'Use the Claude Code default model (currently Sonnet 4.6)', + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'max' }, + ], + }, }, { value: 'fable', label: 'Fable', description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + ], + }, }, { value: "sonnet", label: "Sonnet", description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok", + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'max' }, + ], + }, }, { value: 'sonnet[1m]', label: 'Sonnet (1M context)', description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok', + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'max' }, + ], + }, + }, + { + value: 'opus', + label: 'Opus', + description: 'Opus 4.8 · Best for everyday, complex tasks · ~2× usage vs Sonnet', + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + ], + }, }, { value: 'opus[1m]', label: 'Opus 4.8 (1M context)', description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok', + effort: { + default: 'high', + values: [ + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + ], + }, }, { value: 'haiku', @@ -48,6 +111,15 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { ], DEFAULT: 'default', }; + +export const findClaudeModelOption = (model: string | undefined | null): ProviderModelOption | null => { + const normalizedModel = typeof model === 'string' ? model.trim() : ''; + if (!normalizedModel) { + return null; + } + + return CLAUDE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === normalizedModel) ?? null; +}; type ClaudeInitEvent = { sessionId?: string; session_id?: string; diff --git a/server/modules/providers/list/codex/codex-models.provider.ts b/server/modules/providers/list/codex/codex-models.provider.ts index de4de0ea..95ea85ce 100644 --- a/server/modules/providers/list/codex/codex-models.provider.ts +++ b/server/modules/providers/list/codex/codex-models.provider.ts @@ -21,11 +21,46 @@ import { export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ - { value: 'gpt-5.5', label: 'gpt-5.5' }, - { value: 'gpt-5.4', label: 'gpt-5.4' }, - { value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' }, - { value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' }, - { value: 'gpt-5.2', label: 'gpt-5.2' }, + { + value: 'gpt-5.5', + label: 'gpt-5.5', + effort: { + default: 'medium', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }], + }, + }, + { + value: 'gpt-5.4', + label: 'gpt-5.4', + effort: { + default: 'medium', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }], + }, + }, + { + value: 'gpt-5.4-mini', + label: 'gpt-5.4-mini', + effort: { + default: 'medium', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }], + }, + }, + { + value: 'gpt-5.3-codex', + label: 'gpt-5.3-codex', + effort: { + default: 'medium', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }], + }, + }, + { + value: 'gpt-5.2', + label: 'gpt-5.2', + effort: { + default: 'medium', + values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }, { value: 'xhigh' }], + }, + }, ], DEFAULT: 'gpt-5.4', }; @@ -37,6 +72,11 @@ type CodexCachedModel = { priority?: number; visibility?: string; supported_in_api?: boolean; + default_reasoning_level?: string; + supported_reasoning_levels?: Array<{ + effort?: string; + description?: string; + }>; }; const CODEX_MODELS_CACHE_PATH = path.join(os.homedir(), '.codex', 'models_cache.json'); @@ -55,11 +95,29 @@ const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({ value: model.slug as string, label: readOptionalString(model.display_name) ?? (model.slug as string), description: readOptionalString(model.description), + effort: Array.isArray(model.supported_reasoning_levels) && model.supported_reasoning_levels.length > 0 + ? { + default: readOptionalString(model.default_reasoning_level) ?? undefined, + values: model.supported_reasoning_levels + .map((level) => { + const value = readOptionalString(level?.effort); + if (!value) { + return null; + } + + return { + value, + description: readOptionalString(level?.description), + }; + }) + .filter((level): level is NonNullable => Boolean(level)), + } + : undefined, }); const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => { const sortedModels = [...models] - .filter((model) => model.visibility !== 'hidden' && model.supported_in_api !== false) + .filter((model) => model.visibility === 'list' && model.supported_in_api !== false) .sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority)); const options: ProviderModelOption[] = []; diff --git a/server/modules/providers/services/provider-models.service.ts b/server/modules/providers/services/provider-models.service.ts index 9162df0c..cb183f99 100644 --- a/server/modules/providers/services/provider-models.service.ts +++ b/server/modules/providers/services/provider-models.service.ts @@ -16,7 +16,7 @@ import type { import { readProviderSessionActiveModelChange } from '@/shared/utils.js'; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; -const PROVIDER_MODELS_CACHE_VERSION = 1; +const PROVIDER_MODELS_CACHE_VERSION = 2; const UNCACHED_PROVIDERS = new Set(['claude', 'gemini']); type ProviderModelsServiceDependencies = { diff --git a/server/openai-codex.js b/server/openai-codex.js index 34f5bc05..47e89ab6 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -20,7 +20,6 @@ import { providerAuthService } from './modules/providers/services/provider-auth. import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; -// Track active sessions const activeCodexSessions = new Map(); function readUsageNumber(value) { @@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) { cwd, projectPath, model, + effort, permissionMode = 'default' } = options; @@ -239,6 +239,12 @@ export async function queryCodex(command, options = {}, ws) { const workingDirectory = cwd || projectPath || process.cwd(); const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); + const catalog = (await providerModelsService.getProviderModels('codex')).models; + const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null; + const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; + const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) + ? effort + : undefined; let codex; let thread; @@ -248,19 +254,17 @@ export async function queryCodex(command, options = {}, ws) { const abortController = new AbortController(); try { - // Initialize Codex SDK codex = new Codex(); - // Thread options with sandbox and approval settings const threadOptions = { workingDirectory, skipGitRepoCheck: true, sandboxMode, approvalPolicy, - model: resolvedModel + model: resolvedModel, + modelReasoningEffort: resolvedEffort, }; - // Start or resume thread if (sessionId) { thread = codex.resumeThread(sessionId, threadOptions); } else { @@ -280,12 +284,10 @@ export async function queryCodex(command, options = {}, ws) { }); }; - // Existing sessions can be tracked immediately; new sessions are tracked after thread.started. if (capturedSessionId) { registerSession(capturedSessionId); } - // Execute with streaming const streamedTurn = await thread.runStreamed(command, { signal: abortController.signal }); diff --git a/server/shared/types.ts b/server/shared/types.ts index 5d411efe..c803856b 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -74,6 +74,13 @@ export type ProviderModelOption = { value: string; label: string; description?: string; + effort?: { + default?: string; + values: { + value: string; + description?: string; + }[]; + }; }; /** diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9817b6d4..f9365dfd 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -37,6 +37,8 @@ interface UseChatComposerStateArgs { cursorModel: string; claudeModel: string; codexModel: string; + claudeEffort: string; + codexEffort: string; geminiModel: string; opencodeModel: string; isLoading: boolean; @@ -161,6 +163,17 @@ const getNotificationSessionSummary = ( return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; }; +const getLatestProviderEffort = ( + provider: LLMProvider, + fallbackEffort: string, +): string => { + if (provider !== 'claude' && provider !== 'codex') { + return 'default'; + } + + return safeLocalStorage.getItem(`${provider}-effort`) || fallbackEffort; +}; + export function useChatComposerState({ selectedProject, selectedSession, @@ -171,6 +184,8 @@ export function useChatComposerState({ cursorModel, claudeModel, codexModel, + claudeEffort, + codexEffort, geminiModel, opencodeModel, isLoading, @@ -730,6 +745,12 @@ export function useChatComposerState({ : provider === 'opencode' ? opencodeModel : claudeModel; + const effort = + provider === 'claude' + ? getLatestProviderEffort(provider, claudeEffort) + : provider === 'codex' + ? getLatestProviderEffort(provider, codexEffort) + : 'default'; // One message shape for every provider. The backend resolves the // provider, project path, and provider-native resume id from the @@ -740,6 +761,7 @@ export function useChatComposerState({ content: messageContent, options: { model, + effort, // Codex has no plan mode; downgrade rather than sending an // unsupported value to its runtime. permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, @@ -768,7 +790,9 @@ export function useChatComposerState({ selectedSession, attachedImages, claudeModel, + claudeEffort, codexModel, + codexEffort, currentSessionId, cursorModel, executeCommand, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index ea49d841..3353c429 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -5,18 +5,26 @@ import type { ProjectSession, LLMProvider, Project, + ProviderModelOption, ProviderModelsCacheInfo, ProviderModelsDefinition, } from '../../../types/app'; const FALLBACK_DEFAULT_MODEL: Record = { - claude: 'opus', + claude: 'default', cursor: 'gpt-5.3-codex', codex: 'gpt-5.4', gemini: 'gemini-3.1-pro-preview', opencode: 'anthropic/claude-sonnet-4-5', }; +const DEFAULT_EFFORT_VALUE = 'default'; + +const FALLBACK_EFFORT_VALUES: Partial> = { + claude: ['low', 'medium', 'high', 'xhigh', 'max'], + codex: ['low', 'medium', 'high', 'xhigh'], +}; + /** * Fallback permission-mode matrix used only until the backend capability * matrix (`GET /api/providers/capabilities`) has loaded. The backend is the @@ -87,6 +95,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; }); + const [claudeEffort, setClaudeEffort] = useState(() => { + return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE; + }); + const [codexEffort, setCodexEffort] = useState(() => { + return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE; + }); const [geminiModel, setGeminiModel] = useState(() => { return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; }); @@ -145,6 +159,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh localStorage.setItem('opencode-model', model); }, []); + const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => { + if (targetProvider === 'claude') { + setClaudeEffort(effort); + localStorage.setItem('claude-effort', effort); + return; + } + + if (targetProvider === 'codex') { + setCodexEffort(effort); + localStorage.setItem('codex-effort', effort); + } + }, []); + const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; const requestId = providerModelsRequestIdRef.current + 1; @@ -259,6 +286,53 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return def.DEFAULT; }; + const getModelOption = useCallback(( + targetProvider: LLMProvider, + model: string, + ): ProviderModelOption | null => { + const definition = providerModelCatalog[targetProvider]; + if (!definition) { + return null; + } + + return definition.OPTIONS.find((option) => option.value === model) ?? null; + }, [providerModelCatalog]); + + const getAllowedEffortValues = useCallback(( + targetProvider: LLMProvider, + model: string, + ): string[] => { + const option = getModelOption(targetProvider, model); + return option?.effort?.values.map((value) => value.value) ?? FALLBACK_EFFORT_VALUES[targetProvider] ?? []; + }, [getModelOption]); + + const reconcileStoredEffort = useCallback(( + targetProvider: LLMProvider, + model: string, + currentEffort: string, + ): string => { + const allowedValues = getAllowedEffortValues(targetProvider, model); + if (allowedValues.length === 0) { + return DEFAULT_EFFORT_VALUE; + } + + const storageKey = `${targetProvider}-effort`; + const storedEffort = localStorage.getItem(storageKey); + if (storedEffort === DEFAULT_EFFORT_VALUE || storedEffort === null) { + return DEFAULT_EFFORT_VALUE; + } + + if (allowedValues.includes(storedEffort)) { + return storedEffort; + } + + if (allowedValues.includes(currentEffort)) { + return currentEffort; + } + + return DEFAULT_EFFORT_VALUE; + }, [getAllowedEffortValues]); + useEffect(() => { const claude = providerModelCatalog.claude; if (claude) { @@ -272,6 +346,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.claude, claudeModel]); + useEffect(() => { + const next = reconcileStoredEffort('claude', claudeModel, claudeEffort); + if (next !== claudeEffort) { + setClaudeEffort(next); + } + if ((localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE) !== next) { + localStorage.setItem('claude-effort', next); + } + }, [claudeEffort, claudeModel, reconcileStoredEffort]); + useEffect(() => { const cursor = providerModelCatalog.cursor; if (cursor) { @@ -298,6 +382,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.codex, codexModel]); + useEffect(() => { + const next = reconcileStoredEffort('codex', codexModel, codexEffort); + if (next !== codexEffort) { + setCodexEffort(next); + } + if ((localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE) !== next) { + localStorage.setItem('codex-effort', next); + } + }, [codexEffort, codexModel, reconcileStoredEffort]); + useEffect(() => { const gemini = providerModelCatalog.gemini; if (gemini) { @@ -430,6 +524,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh setClaudeModel, codexModel, setCodexModel, + claudeEffort, + setClaudeEffort, + codexEffort, + setCodexEffort, geminiModel, setGeminiModel, opencodeModel, @@ -445,5 +543,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh providerModelsRefreshing, hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), selectProviderModel, + setStoredProviderEffort, }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index b466c6ba..570ea560 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -16,6 +16,10 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; +const FALLBACK_COMPOSER_EFFORT_OPTIONS = { + claude: ['low', 'medium', 'high', 'xhigh', 'max'].map((value) => ({ value })), + codex: ['low', 'medium', 'high', 'xhigh'].map((value) => ({ value })), +} as const; function ChatInterface({ selectedProject, @@ -71,6 +75,10 @@ function ChatInterface({ setClaudeModel, codexModel, setCodexModel, + claudeEffort, + setClaudeEffort, + codexEffort, + setCodexEffort, geminiModel, setGeminiModel, opencodeModel, @@ -85,6 +93,7 @@ function ChatInterface({ providerModelsRefreshing, hardRefreshProviderModels, selectProviderModel, + setStoredProviderEffort, } = useChatProviderState({ selectedSession, selectedProject, @@ -199,6 +208,8 @@ function ChatInterface({ cursorModel, claudeModel, codexModel, + claudeEffort, + codexEffort, geminiModel, opencodeModel, isLoading: isProcessing, @@ -217,6 +228,37 @@ function ChatInterface({ setPendingPermissionRequests, }); + const currentComposerModel = useMemo(() => { + if (provider === 'claude') return claudeModel; + if (provider === 'codex') return codexModel; + if (provider === 'gemini') return geminiModel; + if (provider === 'opencode') return opencodeModel; + return cursorModel; + }, [provider, claudeModel, codexModel, geminiModel, opencodeModel, cursorModel]); + + const composerEffort = useMemo(() => { + if (provider === 'claude') return claudeEffort; + if (provider === 'codex') return codexEffort; + return 'default'; + }, [provider, claudeEffort, codexEffort]); + + const composerEffortOptions = useMemo(() => { + const modelOption = providerModelCatalog[provider]?.OPTIONS.find((option) => option.value === currentComposerModel); + if (modelOption?.effort?.values?.length) { + return modelOption.effort.values; + } + + if (provider === 'claude') { + return FALLBACK_COMPOSER_EFFORT_OPTIONS.claude; + } + + if (provider === 'codex') { + return FALLBACK_COMPOSER_EFFORT_OPTIONS.codex; + } + + return []; + }, [providerModelCatalog, provider, currentComposerModel]); + // On WebSocket reconnect, re-fetch the current session's messages from the // server so missed streaming events are shown, then re-subscribe — the // `chat_subscribed` ack restores or clears the activity indicator, replays @@ -371,6 +413,9 @@ function ChatInterface({ onAbortSession={handleAbortSession} permissionMode={permissionMode} onModeSwitch={cyclePermissionMode} + effort={composerEffort} + availableEffortOptions={composerEffortOptions} + onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)} tokenBudget={tokenBudget} onShowTokenUsage={showCostModal} slashCommandsCount={slashCommandsCount} diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 6077ca2b..23cc550e 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -11,12 +11,13 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; +import type { ProviderModelOption } from '../../../../types/app'; import { PromptInput, PromptInputHeader, @@ -62,6 +63,9 @@ interface ChatComposerProps { onAbortSession: () => void; permissionMode: PermissionMode | string; onModeSwitch: () => void; + effort: string; + availableEffortOptions: NonNullable['values']; + onSelectEffort: (effort: string) => void; tokenBudget: Record | null; onShowTokenUsage: () => void; slashCommandsCount: number; @@ -116,6 +120,9 @@ export default function ChatComposer({ onAbortSession, permissionMode, onModeSwitch, + effort, + availableEffortOptions, + onSelectEffort, tokenBudget, onShowTokenUsage, slashCommandsCount, @@ -193,6 +200,37 @@ export default function ChatComposer({ ); const isRecording = voiceState === 'recording'; const isTranscribing = voiceState === 'transcribing'; + const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false); + const effortDropdownRef = useRef(null); + const effortOptions = useMemo( + () => [{ value: 'default' }, ...availableEffortOptions], + [availableEffortOptions], + ); + const selectedEffortLabel = effort === 'default' ? 'Default' : effort; + + useEffect(() => { + if (!isEffortDropdownOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!effortDropdownRef.current?.contains(event.target as Node)) { + setIsEffortDropdownOpen(false); + } + }; + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEffortDropdownOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isEffortDropdownOpen]); // Detect if the AskUserQuestion interactive panel is active const hasQuestionPanel = pendingPermissionRequests.some( @@ -386,6 +424,55 @@ export default function ChatComposer({ + {availableEffortOptions.length > 0 && ( +
+ + + {isEffortDropdownOpen && ( +
+ {effortOptions.map((option) => { + const isSelected = option.value === effort; + const label = option.value === 'default' ? 'Default' : option.value; + return ( + + ); + })} +
+ )} +
+ )} + ({ value: model, label: model })); }, [data, liveDefinition]); - const filteredOptions = useMemo(() => { const normalized = query.trim().toLowerCase(); if (!normalized) { diff --git a/src/types/app.ts b/src/types/app.ts index f81c3e26..b2ea97c4 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -4,6 +4,13 @@ export type ProviderModelOption = { value: string; label: string; description?: string; + effort?: { + default?: string; + values: { + value: string; + description?: string; + }[]; + }; }; export type ProviderModelsDefinition = {