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

@@ -16,6 +16,10 @@ import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import CommandResultModal from './subcomponents/CommandResultModal';
const FALLBACK_COMPOSER_EFFORT_OPTIONS = {
claude: ['low', 'medium', 'high', 'xhigh', 'max'].map((value) => ({ value })),
codex: ['low', 'medium', 'high', 'xhigh'].map((value) => ({ value })),
} as const;
function ChatInterface({
selectedProject,
@@ -71,6 +75,10 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
claudeEffort,
setClaudeEffort,
codexEffort,
setCodexEffort,
geminiModel,
setGeminiModel,
opencodeModel,
@@ -85,6 +93,7 @@ function ChatInterface({
providerModelsRefreshing,
hardRefreshProviderModels,
selectProviderModel,
setStoredProviderEffort,
} = useChatProviderState({
selectedSession,
selectedProject,
@@ -199,6 +208,8 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
claudeEffort,
codexEffort,
geminiModel,
opencodeModel,
isLoading: isProcessing,
@@ -217,6 +228,37 @@ function ChatInterface({
setPendingPermissionRequests,
});
const currentComposerModel = useMemo(() => {
if (provider === 'claude') return claudeModel;
if (provider === 'codex') return codexModel;
if (provider === 'gemini') return geminiModel;
if (provider === 'opencode') return opencodeModel;
return cursorModel;
}, [provider, claudeModel, codexModel, geminiModel, opencodeModel, cursorModel]);
const composerEffort = useMemo(() => {
if (provider === 'claude') return claudeEffort;
if (provider === 'codex') return codexEffort;
return 'default';
}, [provider, claudeEffort, codexEffort]);
const composerEffortOptions = useMemo(() => {
const modelOption = providerModelCatalog[provider]?.OPTIONS.find((option) => option.value === currentComposerModel);
if (modelOption?.effort?.values?.length) {
return modelOption.effort.values;
}
if (provider === 'claude') {
return FALLBACK_COMPOSER_EFFORT_OPTIONS.claude;
}
if (provider === 'codex') {
return FALLBACK_COMPOSER_EFFORT_OPTIONS.codex;
}
return [];
}, [providerModelCatalog, provider, currentComposerModel]);
// On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays
@@ -371,6 +413,9 @@ function ChatInterface({
onAbortSession={handleAbortSession}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
effort={composerEffort}
availableEffortOptions={composerEffortOptions}
onSelectEffort={(nextEffort) => setStoredProviderEffort(provider, nextEffort)}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}

View File

@@ -11,12 +11,13 @@ import type {
RefObject,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
import type { ProviderModelOption } from '../../../../types/app';
import {
PromptInput,
PromptInputHeader,
@@ -62,6 +63,9 @@ interface ChatComposerProps {
onAbortSession: () => void;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
effort: string;
availableEffortOptions: NonNullable<ProviderModelOption['effort']>['values'];
onSelectEffort: (effort: string) => void;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
@@ -116,6 +120,9 @@ export default function ChatComposer({
onAbortSession,
permissionMode,
onModeSwitch,
effort,
availableEffortOptions,
onSelectEffort,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
@@ -193,6 +200,37 @@ export default function ChatComposer({
);
const isRecording = voiceState === 'recording';
const isTranscribing = voiceState === 'transcribing';
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
const effortDropdownRef = useRef<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
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -386,6 +424,55 @@ export default function ChatComposer({
</div>
</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} />
<PromptInputButton

View File

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