import capitalize from 'lodash-es/capitalize.js'; import * as React from 'react'; import { useCallback, useMemo, useState } from 'react'; import { has1mContext } from '../utils/context.js'; 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 [marked1MValues, setMarked1MValues] = useState>( () => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []), ); const handleToggle1M = useCallback(() => { if (!focusedValue || focusedValue === NO_PREFERENCE) return; // Key on the base value so lookups in handleSelect / is1MMarked match the // initializer — predefined 1M options arrive with a `[1m]` suffix in // `focusedValue`, which would diverge from the base-value key set. const baseKey = focusedValue.replace(/\[1m\]/i, ''); setMarked1MValues(prev => { const next = new Set(prev); if (next.has(baseKey)) { next.delete(baseKey); } else { next.add(baseKey); } return next; }); }, [focusedValue]); 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 is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue.replace(/\[1m\]/i, '')); 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'), 'modelPicker:toggle1M': () => handleToggle1M(), }, { 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; } // Apply or strip [1m] suffix based on user toggle. marked1MValues is keyed // on the base value (see initializer + handleToggle1M), so look up with the // base form — not `value`, which may carry a `[1m]` suffix from predefined // 1M options and would never match. const baseValue = value.replace(/\[1m\]/i, ''); const wants1M = marked1MValues.has(baseValue); const finalValue = wants1M ? `${baseValue}[1m]` : baseValue; onSelect(finalValue, selectedEffort); } const content = ( Select model {headerText ?? 'Choose a model for this and future sessions. Use ← → to adjust effort, Space to toggle 1M context.'} {sessionModel && ( Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model will undo this. )}