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

@@ -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,

View File

@@ -5,18 +5,26 @@ import type {
ProjectSession,
LLMProvider,
Project,
ProviderModelOption,
ProviderModelsCacheInfo,
ProviderModelsDefinition,
} from '../../../types/app';
const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
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<Record<LLMProvider, string[]>> = {
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<string>(() => {
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>(() => {
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,
};
}