import capitalize from 'lodash-es/capitalize.js' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, } from 'src/utils/fastMode.js' import { Box, Text } from '../ink.js' import { useKeybindings } from '../keybindings/useKeybinding.js' import { useAppState, useSetAppState } from '../state/AppState.js' import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort, } from '../utils/effort.js' import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel, } from '../utils/model/model.js' import { getModelOptions } from '../utils/model/modelOptions.js' import { getAPIProvider } from '../utils/model/providers.js' import { validateModel } from '../utils/model/validateModel.js' import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js' import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' import { Select, type OptionWithDescription } from './CustomSelect/index.js' import { Byline } from './design-system/Byline.js' import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' import { Pane } from './design-system/Pane.js' import { effortLevelToSymbol } from './EffortIndicator.js' export type Props = { initial: string | null sessionModel?: ModelSetting onSelect: (model: string | null, effort: EffortLevel | undefined) => void onCancel?: () => void isStandaloneCommand?: boolean showFastModeNotice?: boolean /** Overrides the dim header line below "Select model". */ headerText?: string /** When true, show a custom input row for exact OpenRouter model IDs. */ allowCustomOpenRouterModelInput?: boolean /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ skipSettingsWrite?: boolean } const NO_PREFERENCE = '__NO_PREFERENCE__' const CUSTOM_OPENROUTER_MODEL = '__CUSTOM_OPENROUTER_MODEL__' export function ModelPicker({ initial, sessionModel, onSelect, onCancel, isStandaloneCommand, showFastModeNotice, headerText, allowCustomOpenRouterModelInput, skipSettingsWrite, }: Props): React.ReactNode { const setAppState = useSetAppState() const exitState = useExitOnCtrlCDWithKeybindings() const initialValue = initial === null ? NO_PREFERENCE : initial const [focusedValue, setFocusedValue] = useState(initialValue) const isFastMode = useAppState(s => isFastModeEnabled() ? s.fastMode : false, ) const [hasToggledEffort, setHasToggledEffort] = useState(false) const effortValue = useAppState(s => s.effortValue) const [effort, setEffort] = useState( effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined, ) const [customOpenRouterModel, setCustomOpenRouterModel] = useState('') const [customModelError, setCustomModelError] = useState(null) const [isValidatingCustomModel, setIsValidatingCustomModel] = useState(false) const shouldShowCustomOpenRouterInput = allowCustomOpenRouterModelInput === true && getAPIProvider() === 'openrouter' const modelOptions = useMemo( () => getModelOptions(isFastMode ?? false), [isFastMode], ) const modelOptionsWithCustomInput = useMemo(() => { if (!shouldShowCustomOpenRouterInput) { return modelOptions } const customOption: OptionWithDescription = { type: 'input', value: CUSTOM_OPENROUTER_MODEL, label: 'Custom OpenRouter model ID', description: 'Type an exact OpenRouter model ID and press Enter', placeholder: 'e.g. anthropic/claude-opus-4.6', initialValue: customOpenRouterModel, onChange: setCustomOpenRouterModel, showLabelWithValue: true, labelValueSeparator: ': ', resetCursorOnUpdate: true, } return [...modelOptions, customOption] }, [ customOpenRouterModel, modelOptions, shouldShowCustomOpenRouterInput, ]) // Ensure the initial value is in the options list. const optionsWithInitial = useMemo(() => { if ( initial !== null && !modelOptionsWithCustomInput.some(opt => opt.value === initial) ) { return [ ...modelOptionsWithCustomInput, { value: initial, label: modelDisplayString(initial), description: 'Current model', }, ] } return modelOptionsWithCustomInput }, [modelOptionsWithCustomInput, initial]) const selectOptions = useMemo( () => optionsWithInitial.map(opt => ({ ...opt, value: opt.value === null ? NO_PREFERENCE : opt.value, })), [optionsWithInitial], ) const initialFocusValue = useMemo( () => selectOptions.some(_ => _.value === initialValue) ? initialValue : (selectOptions[0]?.value ?? undefined), [selectOptions, initialValue], ) const visibleCount = Math.min(10, selectOptions.length) const hiddenCount = Math.max(0, selectOptions.length - visibleCount) const focusedModelName = selectOptions.find( opt => opt.value === focusedValue, )?.label const focusedModel = resolveOptionModel(focusedValue) const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort const handleFocus = useCallback( (value: string) => { setFocusedValue(value) setCustomModelError(null) if (!hasToggledEffort && effortValue === undefined) { setEffort(getDefaultEffortLevelForOption(value)) } }, [hasToggledEffort, effortValue], ) const handleCycleEffort = useCallback( (direction: 'left' | 'right') => { if (!focusedSupportsEffort) return setEffort(prev => cycleEffortLevel( prev ?? focusedDefaultEffort, direction, focusedSupportsMax, ), ) setHasToggledEffort(true) }, [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], ) useKeybindings( { 'modelPicker:decreaseEffort': () => handleCycleEffort('left'), 'modelPicker:increaseEffort': () => handleCycleEffort('right'), }, { context: 'ModelPicker' }, ) const handleSelect = useCallback( async (value: string) => { if (value === CUSTOM_OPENROUTER_MODEL) { const modelId = customOpenRouterModel.trim() if (!modelId) { setCustomModelError('Enter an OpenRouter model ID first.') return } setCustomModelError(null) setIsValidatingCustomModel(true) try { const { valid, error } = await validateModel(modelId) if (!valid) { setCustomModelError(error ?? `Model '${modelId}' not found`) return } onSelect(modelId, undefined) } catch (error) { setCustomModelError( `Failed to validate model: ${ error instanceof Error ? error.message : String(error) }`, ) } finally { setIsValidatingCustomModel(false) } return } logEvent('tengu_model_command_menu_effort', { effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (!skipSettingsWrite) { // Prior comes from userSettings on disk, not merged settings/AppState. const effortLevel = resolvePickerEffortPersistence( effort, getDefaultEffortLevelForOption(value), getSettingsForSource('userSettings')?.effortLevel, hasToggledEffort, ) const persistable = toPersistableEffort(effortLevel) if (persistable !== undefined) { updateSettingsForSource('userSettings', { effortLevel: persistable }) } setAppState(prev => ({ ...prev, effortValue: effortLevel })) } const selectedModel = resolveOptionModel(value) const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined if (value === NO_PREFERENCE) { onSelect(null, selectedEffort) return } onSelect(value, selectedEffort) }, [ customOpenRouterModel, effort, hasToggledEffort, onSelect, setAppState, skipSettingsWrite, ], ) const content = ( Select model {headerText ?? 'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'} {shouldShowCustomOpenRouterInput && ( OpenRouter users can also type an exact model ID below. )} {sessionModel && ( Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model will undo this. )}