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 '@anthropic/ink' 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 { getSettingsForSource, updateSettingsForSource, } from '../utils/settings/settings.js' import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' import { Select } from './CustomSelect/index.js' import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' 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, 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__' export function ModelPicker({ initial, sessionModel, onSelect, onCancel, isStandaloneCommand, showFastModeNotice, headerText, skipSettingsWrite, }: Props): React.ReactNode { const setAppState = useSetAppState() const exitState = useExitOnCtrlCDWithKeybindings() const maxVisible = 10 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, ) // Memoize all derived values to prevent re-renders const modelOptions = useMemo( () => getModelOptions(isFastMode ?? false), [isFastMode], ) // Ensure the initial value is in the options list // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) // is not in the base options but should still be selectable and shown as selected const optionsWithInitial = useMemo(() => { if (initial !== null && !modelOptions.some(opt => opt.value === initial)) { return [ ...modelOptions, { value: initial, label: modelDisplayString(initial), description: 'Current model', }, ] } return modelOptions }, [modelOptions, 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(maxVisible, 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) // Clamp display when 'max' is selected but the focused model doesn't support it. // resolveAppliedEffort() does the same downgrade at API-send time. const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort const handleFocus = useCallback( (value: string) => { setFocusedValue(value) if (!hasToggledEffort && effortValue === undefined) { setEffort(getDefaultEffortLevelForOption(value)) } }, [hasToggledEffort, effortValue], ) // Effort level cycling keybindings 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' }, ) function handleSelect(value: string): void { 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 (which // includes project/policy layers that must not leak into the user's // global ~/.claude/settings.json), and NOT AppState.effortValue (which // includes session-ephemeral sources like --effort CLI flag). // See resolvePickerEffortPersistence JSDoc. 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) } 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.'} {sessionModel && ( Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model will undo this. )}