diff --git a/public/api-docs.html b/public/api-docs.html index 03dbb9b8..040d8680 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -489,7 +489,7 @@ http://localhost:3001/api/agent -

Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.

+

Trigger an AI agent (Claude, Cursor, Codex, Gemini, or OpenCode) to work on a project.

Request Body Parameters

@@ -524,7 +524,7 @@ - + @@ -540,6 +540,12 @@ Model identifier for the AI provider (loading from constants...) + + + + + + diff --git a/server/claude-sdk.js b/server/claude-sdk.js index a0a795c6..426ce029 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -12,11 +12,13 @@ * - WebSocket message streaming */ -import { query } from '@anthropic-ai/claude-agent-sdk'; import crypto from 'crypto'; import { promises as fs } from 'fs'; -import path from 'path'; import os from 'os'; +import path from 'path'; + +import { query } from '@anthropic-ai/claude-agent-sdk'; + import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js'; @@ -41,6 +43,15 @@ 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 +156,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 +169,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 +207,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 +535,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 +656,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..bc157377 100644 --- a/server/modules/providers/list/codex/codex-models.provider.ts +++ b/server/modules/providers/list/codex/codex-models.provider.ts @@ -21,11 +21,30 @@ 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' }], + }, + }, ], DEFAULT: 'gpt-5.4', }; @@ -37,6 +56,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'); @@ -51,15 +75,39 @@ const readCodexPriority = (value: unknown): number => ( typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER ); -const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => ({ - value: model.slug as string, - label: readOptionalString(model.display_name) ?? (model.slug as string), - description: readOptionalString(model.description), -}); +const mapCodexModel = (model: CodexCachedModel): ProviderModelOption => { + const effortValues = Array.isArray(model.supported_reasoning_levels) + ? 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)) + : []; + + return { + value: model.slug as string, + label: readOptionalString(model.display_name) ?? (model.slug as string), + description: readOptionalString(model.description), + effort: effortValues.length > 0 + ? { + default: readOptionalString(model.default_reasoning_level) ?? undefined, + values: effortValues, + } + : 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/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 0e5f7477..6891b4df 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -74,6 +74,13 @@ const VERSION_TOKEN = /^[a-z]\d+$/i; const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/; const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/; +type OpenCodeVerboseModel = { + id?: string; + name?: string; + providerID?: string; + variants?: Record; +}; + export const parseOpenCodeModelsStdout = (stdout: string): string[] => { const ids: string[] = []; @@ -91,6 +98,83 @@ export const parseOpenCodeModelsStdout = (stdout: string): string[] => { return [...new Set(ids)]; }; +const countJsonBraceDelta = (value: string): number => { + let delta = 0; + let inString = false; + let escaped = false; + + for (const character of value) { + if (escaped) { + escaped = false; + continue; + } + + if (character === '\\') { + escaped = inString; + continue; + } + + if (character === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (character === '{') { + delta += 1; + } else if (character === '}') { + delta -= 1; + } + } + + return delta; +}; + +const isOpenCodeVerboseModel = (value: unknown): value is OpenCodeVerboseModel => { + const record = readObjectRecord(value); + return Boolean(record && readOptionalString(record.id)); +}; + +export const parseOpenCodeVerboseModelsStdout = (stdout: string): OpenCodeVerboseModel[] => { + const models: OpenCodeVerboseModel[] = []; + let buffer: string[] = []; + let depth = 0; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (buffer.length === 0) { + if (line === '{') { + buffer = [rawLine]; + depth = 1; + } + continue; + } + + buffer.push(rawLine); + depth += countJsonBraceDelta(rawLine); + + if (depth !== 0) { + continue; + } + + try { + const parsed = JSON.parse(buffer.join('\n')); + if (isOpenCodeVerboseModel(parsed)) { + models.push(parsed); + } + } catch { + // Ignore malformed verbose blocks and fall back to the plain id parser. + } + + buffer = []; + } + + return models; +}; + const formatDateToken = (token: string): string => ( `${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}` ); @@ -155,6 +239,20 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s }; }; +const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => { + const id = readOptionalString(model.id); + if (!id) { + return null; + } + + if (id.includes('/')) { + return id; + } + + const upstreamProvider = readOptionalString(model.providerID); + return upstreamProvider ? `${upstreamProvider}/${id}` : id; +}; + const labelForOpenCodeModelId = (id: string): string => { const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label; if (fallbackLabel) { @@ -170,6 +268,52 @@ const descriptionForOpenCodeModelId = (id: string): string => { return upstreamProvider ? `${upstreamProvider} - ${id}` : id; }; +const readOpenCodeVariantEffort = (key: string, value: unknown): string | null => { + const variant = readObjectRecord(value); + return readOptionalString(variant?.reasoningEffort) + ?? readOptionalString(variant?.effort) + ?? key; +}; + +const readOpenCodeEffortValues = ( + variants: OpenCodeVerboseModel['variants'], +): NonNullable['values'] => { + const effortValues: NonNullable['values'] = []; + const seenValues = new Set(); + + for (const [key, value] of Object.entries(variants ?? {})) { + const effort = readOpenCodeVariantEffort(key, value); + if (!effort || seenValues.has(effort)) { + continue; + } + + seenValues.add(effort); + effortValues.push({ value: effort }); + } + + return effortValues; +}; + +const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => { + const value = readOpenCodeVerboseModelId(model); + if (!value) { + return null; + } + + const effortValues = readOpenCodeEffortValues(model.variants); + + return { + value, + label: readOptionalString(model.name) ?? labelForOpenCodeModelId(value), + description: descriptionForOpenCodeModelId(value), + effort: effortValues.length > 0 + ? { + values: effortValues, + } + : undefined, + }; +}; + export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { const options: ProviderModelOption[] = ids.map((value) => ({ value, @@ -187,6 +331,36 @@ export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDef }; }; +export const buildOpenCodeDefinitionFromVerboseModels = ( + models: OpenCodeVerboseModel[], +): ProviderModelsDefinition => { + const options: ProviderModelOption[] = []; + const seenValues = new Set(); + + for (const model of models) { + const mappedModel = mapOpenCodeVerboseModel(model); + if (!mappedModel || seenValues.has(mappedModel.value)) { + continue; + } + + seenValues.add(mappedModel.value); + options.push(mappedModel); + } + + if (options.length === 0) { + return OPENCODE_FALLBACK_MODELS; + } + + const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value + ?? options[0]?.value + ?? OPENCODE_FALLBACK_MODELS.DEFAULT; + + return { + OPTIONS: options, + DEFAULT: defaultValue, + }; +}; + const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => { if (typeof rawModel === 'string') { const trimmed = rawModel.trim(); @@ -214,7 +388,7 @@ const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => { }; const runOpenCodeModelsCommand = (): Promise => new Promise((resolve, reject) => { - const openCodeProcess = spawnFunction('opencode', ['models'], { + const openCodeProcess = spawnFunction('opencode', ['models', '--verbose'], { cwd: process.cwd(), env: { ...process.env }, }); @@ -273,6 +447,11 @@ export class OpenCodeProviderModels implements IProviderModels { async getSupportedModels(): Promise { try { const stdout = await runOpenCodeModelsCommand(); + const verboseModels = parseOpenCodeVerboseModelsStdout(stdout); + if (verboseModels.length > 0) { + return buildOpenCodeDefinitionFromVerboseModels(verboseModels); + } + const ids = parseOpenCodeModelsStdout(stdout); if (ids.length === 0) { return OPENCODE_FALLBACK_MODELS; diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 1b7cbbb3..dcd5f1ab 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -21,6 +21,8 @@ type ProviderCapabilities = { supportsPermissionRequests: boolean; /** Whether the token-usage endpoint has data for this provider. */ supportsTokenUsage: boolean; + /** Whether the provider runtime can accept model-level reasoning effort. */ + supportsEffort: boolean; }; /** @@ -38,6 +40,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: true, supportsTokenUsage: true, + supportsEffort: true, }, cursor: { provider: 'cursor', @@ -47,6 +50,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: false, + supportsEffort: false, }, codex: { provider: 'codex', @@ -56,6 +60,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: true, }, gemini: { provider: 'gemini', @@ -65,6 +70,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: false, }, opencode: { provider: 'opencode', @@ -74,6 +80,7 @@ const PROVIDER_CAPABILITIES: Record = { supportsAbort: true, supportsPermissionRequests: false, supportsTokenUsage: true, + supportsEffort: true, }, }; 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/modules/providers/tests/opencode-models.test.ts b/server/modules/providers/tests/opencode-models.test.ts index c28ac0ef..097c1272 100644 --- a/server/modules/providers/tests/opencode-models.test.ts +++ b/server/modules/providers/tests/opencode-models.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + buildOpenCodeDefinitionFromVerboseModels, buildOpenCodeDefinitionFromIds, parseOpenCodeModelsStdout, + parseOpenCodeVerboseModelsStdout, } from '@/modules/providers/list/opencode/opencode-models.provider.js'; test('OpenCode models provider parses plain CLI output and removes duplicates', () => { @@ -71,3 +73,63 @@ test('OpenCode models provider formats frontend labels from provider-prefixed id }, ]); }); + +test('OpenCode models provider maps verbose model variants to effort options', () => { + const models = parseOpenCodeVerboseModelsStdout(` +opencode/deepseek-v4-flash-free +{ + "id": "deepseek-v4-flash-free", + "providerID": "opencode", + "name": "DeepSeek V4 Flash Free", + "variants": { + "low": { + "reasoningEffort": "low" + }, + "high": { + "reasoningEffort": "high" + } + } +} +anthropic/claude-sonnet-5 +{ + "id": "claude-sonnet-5", + "providerID": "anthropic", + "name": "Claude Sonnet 5", + "variants": { + "low": { + "effort": "low" + }, + "max": { + "effort": "max" + } + } +} +`); + + const definition = buildOpenCodeDefinitionFromVerboseModels(models); + + assert.deepEqual(definition.OPTIONS, [ + { + value: 'opencode/deepseek-v4-flash-free', + label: 'DeepSeek V4 Flash Free', + description: 'opencode - opencode/deepseek-v4-flash-free', + effort: { + values: [ + { value: 'low' }, + { value: 'high' }, + ], + }, + }, + { + value: 'anthropic/claude-sonnet-5', + label: 'Claude Sonnet 5', + description: 'anthropic - anthropic/claude-sonnet-5', + effort: { + values: [ + { value: 'low' }, + { value: 'max' }, + ], + }, + }, + ]); +}); 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/opencode-cli.js b/server/opencode-cli.js index e8446329..d486d949 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -14,6 +14,14 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const activeOpenCodeProcesses = new Map(); +function resolveOpenCodeEffort(model, effort, modelsDefinition) { + const selectedModel = modelsDefinition?.OPTIONS?.find((option) => option.value === model); + const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || []; + return typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort) + ? effort + : undefined; +} + function readOpenCodeSessionId(event) { if (!event || typeof event !== 'object') { return null; @@ -84,7 +92,7 @@ function readOpenCodeTokenUsage(sessionId) { async function spawnOpenCode(command, options = {}, ws) { return new Promise((resolve, reject) => { - const { sessionId, projectPath, cwd, model, sessionSummary } = options; + const { sessionId, projectPath, cwd, model, effort, sessionSummary } = options; const workingDir = cwd || projectPath || process.cwd(); const processKey = sessionId || Date.now().toString(); let capturedSessionId = sessionId || null; @@ -192,7 +200,15 @@ async function spawnOpenCode(command, options = {}, ws) { } }; - void providerModelsService.resolveResumeModel('opencode', sessionId, model).then((resolvedModel) => { + void providerModelsService.resolveResumeModel('opencode', sessionId, model).then(async (resolvedModel) => { + let effortModels = null; + try { + effortModels = (await providerModelsService.getProviderModels('opencode')).models; + } catch (error) { + console.warn('[OpenCode] Unable to load provider models for effort validation:', error); + } + + const resolvedEffort = resolveOpenCodeEffort(resolvedModel, effort, effortModels); const args = ['run', '--format', 'json']; // OpenCode's `run` command owns workspace selection through `--dir`. // Relying on the child-process cwd alone is not enough on Linux, where @@ -204,6 +220,9 @@ async function spawnOpenCode(command, options = {}, ws) { if (resolvedModel) { args.push('--model', resolvedModel); } + if (resolvedEffort) { + args.push('--variant', resolvedEffort); + } if (command && command.trim()) { args.push(command.trim()); } diff --git a/server/routes/agent.js b/server/routes/agent.js index f2273181..5febbf61 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -646,12 +646,17 @@ class ResponseCollector { * * @param {string} model - (Optional) Model identifier for providers. * - * Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable' + * Claude models: 'default', 'sonnet', 'opus', 'haiku', 'sonnet[1m]', 'opus[1m]', 'fable' * Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5', * 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high', * 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max', * 'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants - * Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini' + * Codex models: 'gpt-5.4' (default), 'gpt-5.5', 'gpt-5.4-mini' + * + * @param {string} effort - (Optional) Reasoning effort for providers/models that support it. + * Claude supports: 'low', 'medium', 'high', 'xhigh', 'max' depending on model. + * Codex supports: 'low', 'medium', 'high', 'xhigh'. + * 'default' or omission lets the provider decide. * * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion. * Default: true @@ -844,6 +849,9 @@ class ResponseCollector { */ router.post('/', validateExternalApiKey, async (req, res) => { const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, sessionId } = req.body; + const effort = typeof req.body.effort === 'string' && req.body.effort.trim() + ? req.body.effort.trim() + : undefined; // Parse stream and cleanup as booleans (handle string "true"/"false" from curl) const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true'); @@ -954,6 +962,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: sessionId || null, model: model, + effort, permissionMode: 'bypassPermissions' // Bypass all permissions for API calls }, writer); @@ -975,6 +984,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: sessionId || null, model: model || codexModels.DEFAULT, + effort, permissionMode: 'bypassPermissions' }, writer); } else if (provider === 'gemini') { @@ -994,7 +1004,8 @@ router.post('/', validateExternalApiKey, async (req, res) => { projectPath: finalProjectPath, cwd: finalProjectPath, sessionId: sessionId || null, - model: model || opencodeModels.DEFAULT + model: model || opencodeModels.DEFAULT, + effort }, writer); } 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/constants/providerEffort.ts b/src/components/chat/constants/providerEffort.ts new file mode 100644 index 00000000..28e26d35 --- /dev/null +++ b/src/components/chat/constants/providerEffort.ts @@ -0,0 +1,13 @@ +import type { LLMProvider, ProviderModelOption } from '../../../types/app'; + +export const DEFAULT_EFFORT_VALUE = 'default'; + +export const FALLBACK_PROVIDER_EFFORT_VALUES: Partial> = { + claude: ['low', 'medium', 'high', 'xhigh', 'max'], + codex: ['low', 'medium', 'high', 'xhigh'], + opencode: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], +}; + +export const toProviderEffortOptions = ( + values: readonly string[], +): NonNullable['values'] => values.map((value) => ({ value })); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 9817b6d4..9dd1bbc0 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -34,9 +34,11 @@ interface UseChatComposerStateArgs { provider: LLMProvider; permissionMode: PermissionMode | string; cyclePermissionMode: () => void; + resolvePermissionModeForProvider: (provider: LLMProvider, requestedMode: PermissionMode | string) => PermissionMode; cursorModel: string; claudeModel: string; codexModel: string; + currentProviderEffort: string; geminiModel: string; opencodeModel: string; isLoading: boolean; @@ -168,9 +170,11 @@ export function useChatComposerState({ provider, permissionMode, cyclePermissionMode, + resolvePermissionModeForProvider, cursorModel, claudeModel, codexModel, + currentProviderEffort, geminiModel, opencodeModel, isLoading, @@ -728,8 +732,9 @@ export function useChatComposerState({ : provider === 'gemini' ? geminiModel : provider === 'opencode' - ? opencodeModel - : claudeModel; + ? opencodeModel + : claudeModel; + const effort = currentProviderEffort; // One message shape for every provider. The backend resolves the // provider, project path, and provider-native resume id from the @@ -740,9 +745,8 @@ export function useChatComposerState({ content: messageContent, options: { model, - // Codex has no plan mode; downgrade rather than sending an - // unsupported value to its runtime. - permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, + effort, + permissionMode: resolvePermissionModeForProvider(provider, permissionMode), toolsSettings, skipPermissions: toolsSettings?.skipPermissions || false, sessionSummary, @@ -769,6 +773,7 @@ export function useChatComposerState({ attachedImages, claudeModel, codexModel, + currentProviderEffort, currentSessionId, cursorModel, executeCommand, @@ -779,6 +784,7 @@ export function useChatComposerState({ onSessionEstablished, permissionMode, provider, + resolvePermissionModeForProvider, resetCommandMenuState, scrollToBottom, selectedProject, diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index ea49d841..86a0eced 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -1,22 +1,31 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + import { authenticatedFetch } from '../../../utils/api'; import type { PendingPermissionRequest, PermissionMode } from '../types/types'; import type { ProjectSession, LLMProvider, Project, + ProviderModelOption, ProviderModelsCacheInfo, ProviderModelsDefinition, } from '../../../types/app'; +import { + DEFAULT_EFFORT_VALUE, + FALLBACK_PROVIDER_EFFORT_VALUES, + toProviderEffortOptions, +} from '../constants/providerEffort'; 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 PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; + /** * Fallback permission-mode matrix used only until the backend capability * matrix (`GET /api/providers/capabilities`) has loaded. The backend is the @@ -39,6 +48,7 @@ type ProviderCapabilities = { supportsAbort: boolean; supportsPermissionRequests: boolean; supportsTokenUsage: boolean; + supportsEffort?: boolean; }; type ProviderCapabilitiesApiResponse = { @@ -72,7 +82,7 @@ type ChangeActiveModelApiResponse = { }; }; -export function useChatProviderState({ selectedSession, selectedProject }: UseChatProviderStateArgs) { +export function useChatProviderState({ selectedSession, selectedProject: _selectedProject }: UseChatProviderStateArgs) { const [permissionMode, setPermissionMode] = useState('default'); const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); const [provider, setProvider] = useState(() => { @@ -87,6 +97,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const [codexModel, setCodexModel] = useState(() => { return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; }); + const [providerEfforts, setProviderEfforts] = useState>>(() => { + return PROVIDERS.reduce>>((acc, targetProvider) => { + acc[targetProvider] = localStorage.getItem(`${targetProvider}-effort`) || DEFAULT_EFFORT_VALUE; + return acc; + }, {}); + }); const [geminiModel, setGeminiModel] = useState(() => { return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; }); @@ -145,8 +161,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh localStorage.setItem('opencode-model', model); }, []); + const setStoredProviderEffort = useCallback((targetProvider: LLMProvider, effort: string) => { + setProviderEfforts((previous) => ( + previous[targetProvider] === effort + ? previous + : { ...previous, [targetProvider]: effort } + )); + localStorage.setItem(`${targetProvider}-effort`, effort); + }, []); + const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => { - const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; const requestId = providerModelsRequestIdRef.current + 1; providerModelsRequestIdRef.current = requestId; const isHardRefresh = options.bypassCache === true; @@ -159,7 +183,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh try { const results = await Promise.all( - providers.map(async (p) => { + PROVIDERS.map(async (p) => { const params = new URLSearchParams(); if (options.bypassCache) { params.set('bypassCache', 'true'); @@ -183,7 +207,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const nextCatalog: Partial> = {}; const nextCacheCatalog: Partial> = {}; - providers.forEach((p, i) => { + PROVIDERS.forEach((p, i) => { const entry = results[i]; if (!entry) { return; @@ -244,6 +268,23 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default']; }, [providerCapabilities]); + const getDefaultPermissionModeForProvider = useCallback((targetProvider: LLMProvider): PermissionMode => { + const modes = getPermissionModesForProvider(targetProvider); + const capabilityDefault = providerCapabilities?.[targetProvider]?.defaultPermissionMode as PermissionMode | undefined; + if (capabilityDefault && modes.includes(capabilityDefault)) { + return capabilityDefault; + } + return modes[0] ?? 'default'; + }, [getPermissionModesForProvider, providerCapabilities]); + + const getSupportsEffortForProvider = useCallback((targetProvider: LLMProvider): boolean => { + const capabilitySupport = providerCapabilities?.[targetProvider]?.supportsEffort; + if (typeof capabilitySupport === 'boolean') { + return capabilitySupport; + } + return Boolean(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider]?.length); + }, [providerCapabilities]); + const pickStoredOrCurrent = ( storageKey: string, current: string, @@ -259,6 +300,70 @@ 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 getEffortOptionsForModel = useCallback(( + targetProvider: LLMProvider, + model: string, + ): NonNullable['values'] => { + if (!getSupportsEffortForProvider(targetProvider)) { + return []; + } + + const option = getModelOption(targetProvider, model); + if (option) { + return option.effort?.values ?? []; + } + + return toProviderEffortOptions(FALLBACK_PROVIDER_EFFORT_VALUES[targetProvider] ?? []); + }, [getModelOption, getSupportsEffortForProvider]); + + const getAllowedEffortValues = useCallback(( + targetProvider: LLMProvider, + model: string, + ): string[] => ( + getEffortOptionsForModel(targetProvider, model).map((value) => value.value) + ), [getEffortOptionsForModel]); + + const reconcileStoredEffort = useCallback(( + targetProvider: LLMProvider, + model: string, + currentEffort: string, + ): string => { + const allowedValues = getAllowedEffortValues(targetProvider, model); + if (allowedValues.length === 0) { + return DEFAULT_EFFORT_VALUE; + } + + if (currentEffort === DEFAULT_EFFORT_VALUE || !currentEffort) { + return DEFAULT_EFFORT_VALUE; + } + + if (allowedValues.includes(currentEffort)) { + return currentEffort; + } + + return DEFAULT_EFFORT_VALUE; + }, [getAllowedEffortValues]); + + const providerModels = useMemo>(() => ({ + claude: claudeModel, + cursor: cursorModel, + codex: codexModel, + gemini: geminiModel, + opencode: opencodeModel, + }), [claudeModel, cursorModel, codexModel, geminiModel, opencodeModel]); + useEffect(() => { const claude = providerModelCatalog.claude; if (claude) { @@ -324,6 +429,27 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [providerModelCatalog.opencode, opencodeModel]); + useEffect(() => { + const nextEfforts: Partial> = {}; + let hasUpdates = false; + + for (const targetProvider of PROVIDERS) { + const currentEffort = providerEfforts[targetProvider] ?? DEFAULT_EFFORT_VALUE; + const nextEffort = reconcileStoredEffort(targetProvider, providerModels[targetProvider], currentEffort); + if (nextEffort === currentEffort) { + continue; + } + + nextEfforts[targetProvider] = nextEffort; + localStorage.setItem(`${targetProvider}-effort`, nextEffort); + hasUpdates = true; + } + + if (hasUpdates) { + setProviderEfforts((previous) => ({ ...previous, ...nextEfforts })); + } + }, [providerEfforts, providerModels, reconcileStoredEffort]); + useEffect(() => { if (!selectedSession?.id) { return; @@ -331,8 +457,12 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); - setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default'); - }, [selectedSession?.id, provider, getPermissionModesForProvider]); + setPermissionMode( + savedMode && validModes.includes(savedMode) + ? savedMode + : getDefaultPermissionModeForProvider(provider), + ); + }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]); useEffect(() => { if (!selectedSession?.__provider || selectedSession.__provider === provider) { @@ -386,6 +516,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh } }, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]); + const resolvePermissionModeForProvider = useCallback(( + targetProvider: LLMProvider, + requestedMode: PermissionMode | string, + ): PermissionMode => { + const validModes = getPermissionModesForProvider(targetProvider); + return validModes.includes(requestedMode as PermissionMode) + ? requestedMode as PermissionMode + : getDefaultPermissionModeForProvider(targetProvider); + }, [getDefaultPermissionModeForProvider, getPermissionModesForProvider]); + const selectProviderModel = useCallback(async ( targetProvider: LLMProvider, model: string, @@ -421,6 +561,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh }; }, [setStoredProviderModel]); + const currentProviderEffortOptions = useMemo(() => { + return getEffortOptionsForModel(provider, providerModels[provider]); + }, [getEffortOptionsForModel, provider, providerModels]); + const currentProviderEffort = useMemo(() => { + return reconcileStoredEffort( + provider, + providerModels[provider], + providerEfforts[provider] ?? DEFAULT_EFFORT_VALUE, + ); + }, [provider, providerEfforts, providerModels, reconcileStoredEffort]); + return { provider, setProvider, @@ -430,6 +581,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh setClaudeModel, codexModel, setCodexModel, + currentProviderEffort, + currentProviderEffortOptions, geminiModel, setGeminiModel, opencodeModel, @@ -445,5 +598,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh providerModelsRefreshing, hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), selectProviderModel, + setStoredProviderEffort, + resolvePermissionModeForProvider, }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2fdcba57..7544ee44 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -17,7 +17,6 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; - function ChatInterface({ selectedProject, selectedSession, @@ -70,6 +69,8 @@ function ChatInterface({ setClaudeModel, codexModel, setCodexModel, + currentProviderEffort, + currentProviderEffortOptions, geminiModel, setGeminiModel, opencodeModel, @@ -84,6 +85,8 @@ function ChatInterface({ providerModelsRefreshing, hardRefreshProviderModels, selectProviderModel, + setStoredProviderEffort, + resolvePermissionModeForProvider, } = useChatProviderState({ selectedSession, selectedProject, @@ -197,6 +200,7 @@ function ChatInterface({ cursorModel, claudeModel, codexModel, + currentProviderEffort, geminiModel, opencodeModel, isLoading: isProcessing, @@ -213,6 +217,7 @@ function ChatInterface({ addMessage, setIsUserScrolledUp, setPendingPermissionRequests, + resolvePermissionModeForProvider, }); // On WebSocket reconnect, re-fetch the current session's messages from the @@ -383,6 +388,9 @@ function ChatInterface({ onAbortSession={handleAbortSession} permissionMode={permissionMode} onModeSwitch={cyclePermissionMode} + effort={currentProviderEffort} + availableEffortOptions={currentProviderEffortOptions} + 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 cc3f397c..ba35dfd8 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { ChangeEvent, ClipboardEvent, @@ -11,12 +11,13 @@ import type { RefObject, TouchEvent, } from 'react'; -import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react'; +import { ImageIcon, MessageSquareIcon, XIcon, 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; @@ -114,6 +118,9 @@ export default function ChatComposer({ onAbortSession, permissionMode, onModeSwitch, + effort, + availableEffortOptions, + onSelectEffort, tokenBudget, onShowTokenUsage, slashCommandsCount, @@ -167,7 +174,7 @@ export default function ChatComposer({ left: textareaRect ? textareaRect.left : 16, bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, }; - }, [input, isCommandMenuOpen, textareaRef]); + }, [isCommandMenuOpen, textareaRef]); // Voice state is hosted here (not in the mic button) so the main Send button can stop // recording and send the transcript in one tap, the way the mic button drops it in the box. @@ -189,6 +196,67 @@ export default function ChatComposer({ ); const isRecording = voiceState === 'recording'; const isTranscribing = voiceState === 'transcribing'; + const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false); + const effortDropdownRef = useRef(null); + const effortDropdownMenuRef = useRef(null); + const effortDropdownButtonRef = useRef(null); + const [effortDropdownPosition, setEffortDropdownPosition] = useState<{ + left: number; + top: number; + maxHeight: number; + } | null>(null); + const effortOptions = useMemo( + () => [{ value: 'default' }, ...availableEffortOptions], + [availableEffortOptions], + ); + const selectedEffortLabel = effort === 'default' ? 'Default' : effort; + const updateEffortDropdownPosition = useCallback(() => { + const rect = effortDropdownButtonRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + setEffortDropdownPosition({ + left: rect.left, + top: rect.top - 8, + maxHeight: Math.max(96, rect.top - 16), + }); + }, []); + + useEffect(() => { + if (!isEffortDropdownOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if ( + !effortDropdownRef.current?.contains(target) + && !effortDropdownMenuRef.current?.contains(target) + ) { + setIsEffortDropdownOpen(false); + } + }; + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setIsEffortDropdownOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + window.addEventListener('resize', updateEffortDropdownPosition); + window.addEventListener('scroll', updateEffortDropdownPosition, true); + window.addEventListener('keydown', handleKeyDown, { capture: true }); + updateEffortDropdownPosition(); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + window.removeEventListener('resize', updateEffortDropdownPosition); + window.removeEventListener('scroll', updateEffortDropdownPosition, true); + window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }; + }, [isEffortDropdownOpen, updateEffortDropdownPosition]); // Detect if the AskUserQuestion interactive panel is active const hasQuestionPanel = pendingPermissionRequests.some( @@ -376,6 +444,70 @@ export default function ChatComposer({ + {availableEffortOptions.length > 0 && ( +
+ + + {isEffortDropdownOpen && effortDropdownPosition && createPortal( +
+ {effortOptions.map((option) => { + const isSelected = option.value === effort; + const label = option.value === 'default' ? 'Default' : option.value; + return ( + + ); + })} +
, + document.body, + )} +
+ )} + ({ 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 = {
provider string Optionalclaude, cursor, or codex (default: claude)claude, cursor, codex, gemini, or opencode (default: claude)
stream
effortstringOptionalReasoning effort for Claude, Codex, and OpenCode models that expose effort metadata. Use default or omit it to let the provider decide.
cleanup boolean