feat: add Claude and Codex effort controls

This commit is contained in:
Simos Mikelatos
2026-06-30 23:29:57 +00:00
parent 2ebe64f218
commit d618abb075
12 changed files with 447 additions and 42 deletions

View File

@@ -41,6 +41,16 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); 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() { function createRequestId() {
if (typeof crypto.randomUUID === 'function') { if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -145,13 +155,8 @@ function matchesToolPermission(entry, toolName, input) {
return false; return false;
} }
/**
* Maps CLI options to SDK-compatible options format
* @param {Object} options - CLI options
* @returns {Object} SDK-compatible options
*/
function mapCliOptionsToSDK(options = {}) { function mapCliOptionsToSDK(options = {}) {
const { sessionId, cwd, toolsSettings, permissionMode } = options; const { sessionId, cwd, toolsSettings, permissionMode, effort } = options;
const sdkOptions = {}; const sdkOptions = {};
@@ -163,32 +168,26 @@ function mapCliOptionsToSDK(options = {}) {
// which does not reliably follow npm's shell wrappers like cross-spawn does. // which does not reliably follow npm's shell wrappers like cross-spawn does.
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH); sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// Map working directory
if (cwd) { if (cwd) {
sdkOptions.cwd = cwd; sdkOptions.cwd = cwd;
} }
// Map permission mode
if (permissionMode && permissionMode !== 'default') { if (permissionMode && permissionMode !== 'default') {
sdkOptions.permissionMode = permissionMode; sdkOptions.permissionMode = permissionMode;
} }
// Map tool settings
const settings = toolsSettings || { const settings = toolsSettings || {
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
skipPermissions: false skipPermissions: false
}; };
// Handle tool permissions
if (settings.skipPermissions && permissionMode !== 'plan') { if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions'; sdkOptions.permissionMode = 'bypassPermissions';
} }
let allowedTools = [...(settings.allowedTools || [])]; let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') { if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) { for (const tool of planModeTools) {
@@ -207,22 +206,24 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || []; 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; 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 = { sdkOptions.systemPrompt = {
type: 'preset', 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']; sdkOptions.settingSources = ['project', 'user', 'local'];
// Map resume session
if (sessionId) { if (sessionId) {
sdkOptions.resume = sessionId; sdkOptions.resume = sessionId;
} }
@@ -533,20 +534,24 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionId, sessionId,
options.model, 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({ const sdkOptions = mapCliOptionsToSDK({
...options, ...options,
model: resolvedModel || options.model, model: resolvedModel || options.model,
effortModels,
}); });
// Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd); const mcpServers = await loadMcpConfig(options.cwd);
if (mcpServers) { if (mcpServers) {
sdkOptions.mcpServers = mcpServers; sdkOptions.mcpServers = mcpServers;
} }
// Handle images - save to temp files and modify prompt
const imageResult = await handleImages(command, options.images, options.cwd); const imageResult = await handleImages(command, options.images, options.cwd);
const finalCommand = imageResult.modifiedCommand; const finalCommand = imageResult.modifiedCommand;
tempImagePaths = imageResult.tempImagePaths; tempImagePaths = imageResult.tempImagePaths;
@@ -650,7 +655,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' }; 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; const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000'; process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';

View File

@@ -5,6 +5,7 @@ import type { IProviderModels } from '@/shared/interfaces.js';
import type { import type {
ProviderChangeActiveModelInput, ProviderChangeActiveModelInput,
ProviderCurrentActiveModel, ProviderCurrentActiveModel,
ProviderModelOption,
ProviderModelsDefinition, ProviderModelsDefinition,
ProviderSessionActiveModelChange, ProviderSessionActiveModelChange,
} from '@/shared/types.js'; } from '@/shared/types.js';
@@ -18,27 +19,89 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{ {
value: 'default', value: 'default',
label: 'Default (recommended)', 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', value: 'fable',
label: 'Fable', label: 'Fable',
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus', 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", value: "sonnet",
label: "Sonnet", label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok", 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]', value: 'sonnet[1m]',
label: 'Sonnet (1M context)', label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok', 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]', value: 'opus[1m]',
label: 'Opus 4.8 (1M context)', label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok', 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', value: 'haiku',
@@ -48,6 +111,15 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
], ],
DEFAULT: 'default', 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 = { type ClaudeInitEvent = {
sessionId?: string; sessionId?: string;
session_id?: string; session_id?: string;

View File

@@ -21,11 +21,46 @@ import {
export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = { export const CODEX_FALLBACK_MODELS: ProviderModelsDefinition = {
OPTIONS: [ OPTIONS: [
{ value: 'gpt-5.5', label: 'gpt-5.5' }, {
{ value: 'gpt-5.4', label: 'gpt-5.4' }, value: 'gpt-5.5',
{ value: 'gpt-5.4-mini', label: 'gpt-5.4-mini' }, label: 'gpt-5.5',
{ value: 'gpt-5.3-codex', label: 'gpt-5.3-codex' }, effort: {
{ value: 'gpt-5.2', label: 'gpt-5.2' }, 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', DEFAULT: 'gpt-5.4',
}; };
@@ -37,6 +72,11 @@ type CodexCachedModel = {
priority?: number; priority?: number;
visibility?: string; visibility?: string;
supported_in_api?: boolean; 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'); 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, value: model.slug as string,
label: readOptionalString(model.display_name) ?? (model.slug as string), label: readOptionalString(model.display_name) ?? (model.slug as string),
description: readOptionalString(model.description), 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<typeof level> => Boolean(level)),
}
: undefined,
}); });
const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => { const buildCodexModelsDefinition = (models: CodexCachedModel[]): ProviderModelsDefinition => {
const sortedModels = [...models] 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)); .sort((left, right) => readCodexPriority(left.priority) - readCodexPriority(right.priority));
const options: ProviderModelOption[] = []; const options: ProviderModelOption[] = [];

View File

@@ -16,7 +16,7 @@ import type {
import { readProviderSessionActiveModelChange } from '@/shared/utils.js'; import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; 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<LLMProvider>(['claude', 'gemini']); const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude', 'gemini']);
type ProviderModelsServiceDependencies = { type ProviderModelsServiceDependencies = {

View File

@@ -20,7 +20,6 @@ import { providerAuthService } from './modules/providers/services/provider-auth.
import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js'; import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Track active sessions
const activeCodexSessions = new Map(); const activeCodexSessions = new Map();
function readUsageNumber(value) { function readUsageNumber(value) {
@@ -228,6 +227,7 @@ export async function queryCodex(command, options = {}, ws) {
cwd, cwd,
projectPath, projectPath,
model, model,
effort,
permissionMode = 'default' permissionMode = 'default'
} = options; } = options;
@@ -239,6 +239,12 @@ export async function queryCodex(command, options = {}, ws) {
const workingDirectory = cwd || projectPath || process.cwd(); const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode); 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 codex;
let thread; let thread;
@@ -248,19 +254,17 @@ export async function queryCodex(command, options = {}, ws) {
const abortController = new AbortController(); const abortController = new AbortController();
try { try {
// Initialize Codex SDK
codex = new Codex(); codex = new Codex();
// Thread options with sandbox and approval settings
const threadOptions = { const threadOptions = {
workingDirectory, workingDirectory,
skipGitRepoCheck: true, skipGitRepoCheck: true,
sandboxMode, sandboxMode,
approvalPolicy, approvalPolicy,
model: resolvedModel model: resolvedModel,
modelReasoningEffort: resolvedEffort,
}; };
// Start or resume thread
if (sessionId) { if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions); thread = codex.resumeThread(sessionId, threadOptions);
} else { } 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) { if (capturedSessionId) {
registerSession(capturedSessionId); registerSession(capturedSessionId);
} }
// Execute with streaming
const streamedTurn = await thread.runStreamed(command, { const streamedTurn = await thread.runStreamed(command, {
signal: abortController.signal signal: abortController.signal
}); });

View File

@@ -74,6 +74,13 @@ export type ProviderModelOption = {
value: string; value: string;
label: string; label: string;
description?: string; description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
}; };
/** /**

View File

@@ -37,6 +37,8 @@ interface UseChatComposerStateArgs {
cursorModel: string; cursorModel: string;
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
claudeEffort: string;
codexEffort: string;
geminiModel: string; geminiModel: string;
opencodeModel: string; opencodeModel: string;
isLoading: boolean; isLoading: boolean;
@@ -161,6 +163,17 @@ const getNotificationSessionSummary = (
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback; 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({ export function useChatComposerState({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -171,6 +184,8 @@ export function useChatComposerState({
cursorModel, cursorModel,
claudeModel, claudeModel,
codexModel, codexModel,
claudeEffort,
codexEffort,
geminiModel, geminiModel,
opencodeModel, opencodeModel,
isLoading, isLoading,
@@ -730,6 +745,12 @@ export function useChatComposerState({
: provider === 'opencode' : provider === 'opencode'
? opencodeModel ? opencodeModel
: claudeModel; : claudeModel;
const effort =
provider === 'claude'
? getLatestProviderEffort(provider, claudeEffort)
: provider === 'codex'
? getLatestProviderEffort(provider, codexEffort)
: 'default';
// One message shape for every provider. The backend resolves the // One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the // provider, project path, and provider-native resume id from the
@@ -740,6 +761,7 @@ export function useChatComposerState({
content: messageContent, content: messageContent,
options: { options: {
model, model,
effort,
// Codex has no plan mode; downgrade rather than sending an // Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime. // unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode, permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
@@ -768,7 +790,9 @@ export function useChatComposerState({
selectedSession, selectedSession,
attachedImages, attachedImages,
claudeModel, claudeModel,
claudeEffort,
codexModel, codexModel,
codexEffort,
currentSessionId, currentSessionId,
cursorModel, cursorModel,
executeCommand, executeCommand,

View File

@@ -5,18 +5,26 @@ import type {
ProjectSession, ProjectSession,
LLMProvider, LLMProvider,
Project, Project,
ProviderModelOption,
ProviderModelsCacheInfo, ProviderModelsCacheInfo,
ProviderModelsDefinition, ProviderModelsDefinition,
} from '../../../types/app'; } from '../../../types/app';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = { const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
claude: 'opus', claude: 'default',
cursor: 'gpt-5.3-codex', cursor: 'gpt-5.3-codex',
codex: 'gpt-5.4', codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview', gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5', opencode: 'anthropic/claude-sonnet-4-5',
}; };
const DEFAULT_EFFORT_VALUE = 'default';
const FALLBACK_EFFORT_VALUES: Partial<Record<LLMProvider, string[]>> = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'],
codex: ['low', 'medium', 'high', 'xhigh'],
};
/** /**
* Fallback permission-mode matrix used only until the backend capability * Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the * 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<string>(() => { const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex; return localStorage.getItem('codex-model') || FALLBACK_DEFAULT_MODEL.codex;
}); });
const [claudeEffort, setClaudeEffort] = useState<string>(() => {
return localStorage.getItem('claude-effort') || DEFAULT_EFFORT_VALUE;
});
const [codexEffort, setCodexEffort] = useState<string>(() => {
return localStorage.getItem('codex-effort') || DEFAULT_EFFORT_VALUE;
});
const [geminiModel, setGeminiModel] = useState<string>(() => { const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini; return localStorage.getItem('gemini-model') || FALLBACK_DEFAULT_MODEL.gemini;
}); });
@@ -145,6 +159,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
localStorage.setItem('opencode-model', model); 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 loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode']; const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const requestId = providerModelsRequestIdRef.current + 1; const requestId = providerModelsRequestIdRef.current + 1;
@@ -259,6 +286,53 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return def.DEFAULT; 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(() => { useEffect(() => {
const claude = providerModelCatalog.claude; const claude = providerModelCatalog.claude;
if (claude) { if (claude) {
@@ -272,6 +346,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [providerModelCatalog.claude, claudeModel]); }, [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(() => { useEffect(() => {
const cursor = providerModelCatalog.cursor; const cursor = providerModelCatalog.cursor;
if (cursor) { if (cursor) {
@@ -298,6 +382,16 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
} }
}, [providerModelCatalog.codex, codexModel]); }, [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(() => { useEffect(() => {
const gemini = providerModelCatalog.gemini; const gemini = providerModelCatalog.gemini;
if (gemini) { if (gemini) {
@@ -430,6 +524,10 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setClaudeModel, setClaudeModel,
codexModel, codexModel,
setCodexModel, setCodexModel,
claudeEffort,
setClaudeEffort,
codexEffort,
setCodexEffort,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel, opencodeModel,
@@ -445,5 +543,6 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
providerModelsRefreshing, providerModelsRefreshing,
hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }), hardRefreshProviderModels: () => loadProviderModels({ bypassCache: true }),
selectProviderModel, selectProviderModel,
setStoredProviderEffort,
}; };
} }

View File

@@ -16,6 +16,10 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer'; import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal'; 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({ function ChatInterface({
selectedProject, selectedProject,
@@ -71,6 +75,10 @@ function ChatInterface({
setClaudeModel, setClaudeModel,
codexModel, codexModel,
setCodexModel, setCodexModel,
claudeEffort,
setClaudeEffort,
codexEffort,
setCodexEffort,
geminiModel, geminiModel,
setGeminiModel, setGeminiModel,
opencodeModel, opencodeModel,
@@ -85,6 +93,7 @@ function ChatInterface({
providerModelsRefreshing, providerModelsRefreshing,
hardRefreshProviderModels, hardRefreshProviderModels,
selectProviderModel, selectProviderModel,
setStoredProviderEffort,
} = useChatProviderState({ } = useChatProviderState({
selectedSession, selectedSession,
selectedProject, selectedProject,
@@ -199,6 +208,8 @@ function ChatInterface({
cursorModel, cursorModel,
claudeModel, claudeModel,
codexModel, codexModel,
claudeEffort,
codexEffort,
geminiModel, geminiModel,
opencodeModel, opencodeModel,
isLoading: isProcessing, isLoading: isProcessing,
@@ -217,6 +228,37 @@ function ChatInterface({
setPendingPermissionRequests, 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 // On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-subscribe — the // server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays // `chat_subscribed` ack restores or clears the activity indicator, replays
@@ -371,6 +413,9 @@ function ChatInterface({
onAbortSession={handleAbortSession} onAbortSession={handleAbortSession}
permissionMode={permissionMode} permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
effort={composerEffort}
availableEffortOptions={composerEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget} tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal} onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}

View File

@@ -11,12 +11,13 @@ import type {
RefObject, RefObject,
TouchEvent, TouchEvent,
} from 'react'; } 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 { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import { import {
PromptInput, PromptInput,
PromptInputHeader, PromptInputHeader,
@@ -62,6 +63,9 @@ interface ChatComposerProps {
onAbortSession: () => void; onAbortSession: () => void;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
onModeSwitch: () => void; onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void; onShowTokenUsage: () => void;
slashCommandsCount: number; slashCommandsCount: number;
@@ -116,6 +120,9 @@ export default function ChatComposer({
onAbortSession, onAbortSession,
permissionMode, permissionMode,
onModeSwitch, onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget, tokenBudget,
onShowTokenUsage, onShowTokenUsage,
slashCommandsCount, slashCommandsCount,
@@ -193,6 +200,37 @@ export default function ChatComposer({
); );
const isRecording = voiceState === 'recording'; const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing'; const isTranscribing = voiceState === 'transcribing';
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
const effortDropdownRef = useRef<HTMLDivElement | null>(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 // Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some( const hasQuestionPanel = pendingPermissionRequests.some(
@@ -386,6 +424,55 @@ export default function ChatComposer({
</div> </div>
</button> </button>
{availableEffortOptions.length > 0 && (
<div ref={effortDropdownRef} className="relative">
<button
type="button"
onClick={() => setIsEffortDropdownOpen((current) => !current)}
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
aria-haspopup="menu"
aria-expanded={isEffortDropdownOpen}
aria-label="Select reasoning effort"
title="Select reasoning effort"
>
<span className="hidden text-[11px] text-muted-foreground sm:inline">Effort</span>
<span className="max-w-16 truncate capitalize sm:max-w-20">{selectedEffortLabel}</span>
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isEffortDropdownOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 min-w-36 overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg">
{effortOptions.map((option) => {
const isSelected = option.value === effort;
const label = option.value === 'default' ? 'Default' : option.value;
return (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={isSelected}
onClick={() => {
onSelectEffort(option.value);
setIsEffortDropdownOpen(false);
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs capitalize transition-colors ${
isSelected
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/70 hover:text-foreground'
}`}
>
<span className="flex h-3 w-3 items-center justify-center">
{isSelected && <Check className="h-3 w-3 text-primary" />}
</span>
<span>{label}</span>
</button>
);
})}
</div>
)}
</div>
)}
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} /> <TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<PromptInputButton <PromptInputButton

View File

@@ -263,7 +263,6 @@ function ModelsContent({
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : []; const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
return availableModels.map((model) => ({ value: model, label: model })); return availableModels.map((model) => ({ value: model, label: model }));
}, [data, liveDefinition]); }, [data, liveDefinition]);
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
if (!normalized) { if (!normalized) {

View File

@@ -4,6 +4,13 @@ export type ProviderModelOption = {
value: string; value: string;
label: string; label: string;
description?: string; description?: string;
effort?: {
default?: string;
values: {
value: string;
description?: string;
}[];
};
}; };
export type ProviderModelsDefinition = { export type ProviderModelsDefinition = {