mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加 model/provider 层改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
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 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'
|
||||
} 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'
|
||||
} 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,
|
||||
@@ -24,42 +24,39 @@ import {
|
||||
modelSupportsMaxEffort,
|
||||
resolvePickerEffortPersistence,
|
||||
toPersistableEffort,
|
||||
} from '../utils/effort.js'
|
||||
} 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'
|
||||
} 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
|
||||
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
|
||||
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
|
||||
}
|
||||
skipSettingsWrite?: boolean;
|
||||
};
|
||||
|
||||
const NO_PREFERENCE = '__NO_PREFERENCE__'
|
||||
const NO_PREFERENCE = '__NO_PREFERENCE__';
|
||||
|
||||
export function ModelPicker({
|
||||
initial,
|
||||
@@ -71,49 +68,44 @@ export function ModelPicker({
|
||||
headerText,
|
||||
skipSettingsWrite,
|
||||
}: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const exitState = useExitOnCtrlCDWithKeybindings()
|
||||
const maxVisible = 10
|
||||
const setAppState = useSetAppState();
|
||||
const exitState = useExitOnCtrlCDWithKeybindings();
|
||||
const maxVisible = 10;
|
||||
|
||||
const initialValue = initial === null ? NO_PREFERENCE : initial
|
||||
const [focusedValue, setFocusedValue] = useState<string | undefined>(
|
||||
initialValue,
|
||||
)
|
||||
const initialValue = initial === null ? NO_PREFERENCE : initial;
|
||||
const [focusedValue, setFocusedValue] = useState<string | undefined>(initialValue);
|
||||
|
||||
const isFastMode = useAppState(s =>
|
||||
isFastModeEnabled() ? s.fastMode : false,
|
||||
)
|
||||
const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false));
|
||||
|
||||
const [marked1MValues, setMarked1MValues] = useState<Set<string>>(
|
||||
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : [])
|
||||
)
|
||||
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []),
|
||||
);
|
||||
|
||||
const handleToggle1M = useCallback(() => {
|
||||
if (!focusedValue || focusedValue === NO_PREFERENCE) return
|
||||
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(focusedValue)) {
|
||||
next.delete(focusedValue)
|
||||
const next = new Set(prev);
|
||||
if (next.has(baseKey)) {
|
||||
next.delete(baseKey);
|
||||
} else {
|
||||
next.add(focusedValue)
|
||||
next.add(baseKey);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [focusedValue])
|
||||
return next;
|
||||
});
|
||||
}, [focusedValue]);
|
||||
|
||||
const [hasToggledEffort, setHasToggledEffort] = useState(false)
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
const [hasToggledEffort, setHasToggledEffort] = useState(false);
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
const [effort, setEffort] = useState<EffortLevel | undefined>(
|
||||
effortValue !== undefined
|
||||
? convertEffortValueToLevel(effortValue)
|
||||
: undefined,
|
||||
)
|
||||
effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined,
|
||||
);
|
||||
|
||||
// Memoize all derived values to prevent re-renders
|
||||
const modelOptions = useMemo(
|
||||
() => getModelOptions(isFastMode ?? false),
|
||||
[isFastMode],
|
||||
)
|
||||
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)
|
||||
@@ -127,10 +119,10 @@ export function ModelPicker({
|
||||
label: modelDisplayString(initial),
|
||||
description: 'Current model',
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
return modelOptions
|
||||
}, [modelOptions, initial])
|
||||
return modelOptions;
|
||||
}, [modelOptions, initial]);
|
||||
|
||||
const selectOptions = useMemo(
|
||||
() =>
|
||||
@@ -139,59 +131,46 @@ export function ModelPicker({
|
||||
value: opt.value === null ? NO_PREFERENCE : opt.value,
|
||||
})),
|
||||
[optionsWithInitial],
|
||||
)
|
||||
);
|
||||
const initialFocusValue = useMemo(
|
||||
() =>
|
||||
selectOptions.some(_ => _.value === initialValue)
|
||||
? initialValue
|
||||
: (selectOptions[0]?.value ?? undefined),
|
||||
() => (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 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)
|
||||
const focusedSupportsEffort = focusedModel
|
||||
? modelSupportsEffort(focusedModel)
|
||||
: false
|
||||
const focusedSupportsMax = focusedModel
|
||||
? modelSupportsMaxEffort(focusedModel)
|
||||
: false
|
||||
const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue)
|
||||
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 displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort;
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(value: string) => {
|
||||
setFocusedValue(value)
|
||||
setFocusedValue(value);
|
||||
if (!hasToggledEffort && effortValue === undefined) {
|
||||
setEffort(getDefaultEffortLevelForOption(value))
|
||||
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)
|
||||
if (!focusedSupportsEffort) return;
|
||||
setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax));
|
||||
setHasToggledEffort(true);
|
||||
},
|
||||
[focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort],
|
||||
)
|
||||
);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
@@ -200,13 +179,12 @@ export function ModelPicker({
|
||||
'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,
|
||||
})
|
||||
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
|
||||
@@ -218,28 +196,28 @@ export function ModelPicker({
|
||||
getDefaultEffortLevelForOption(value),
|
||||
getSettingsForSource('userSettings')?.effortLevel,
|
||||
hasToggledEffort,
|
||||
)
|
||||
const persistable = toPersistableEffort(effortLevel)
|
||||
);
|
||||
const persistable = toPersistableEffort(effortLevel);
|
||||
if (persistable !== undefined) {
|
||||
updateSettingsForSource('userSettings', { effortLevel: persistable })
|
||||
updateSettingsForSource('userSettings', { effortLevel: persistable });
|
||||
}
|
||||
setAppState(prev => ({ ...prev, effortValue: effortLevel }))
|
||||
setAppState(prev => ({ ...prev, effortValue: effortLevel }));
|
||||
}
|
||||
|
||||
const selectedModel = resolveOptionModel(value)
|
||||
const selectedEffort =
|
||||
hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel)
|
||||
? effort
|
||||
: undefined
|
||||
const selectedModel = resolveOptionModel(value);
|
||||
const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined;
|
||||
if (value === NO_PREFERENCE) {
|
||||
onSelect(null, selectedEffort)
|
||||
return
|
||||
onSelect(null, selectedEffort);
|
||||
return;
|
||||
}
|
||||
// Apply or strip [1m] suffix based on user toggle
|
||||
const wants1M = marked1MValues.has(value)
|
||||
const baseValue = value.replace(/\[1m\]/i, '')
|
||||
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
|
||||
onSelect(finalValue, selectedEffort)
|
||||
// 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 = (
|
||||
@@ -255,8 +233,8 @@ export function ModelPicker({
|
||||
</Text>
|
||||
{sessionModel && (
|
||||
<Text dimColor>
|
||||
Currently using {modelDisplayString(sessionModel)} for this
|
||||
session (set by plan mode). Selecting a model will undo this.
|
||||
Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model
|
||||
will undo this.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -283,10 +261,8 @@ export function ModelPicker({
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
{focusedSupportsEffort ? (
|
||||
<Text dimColor>
|
||||
<EffortLevelIndicator effort={displayEffort} />{' '}
|
||||
{capitalize(displayEffort)} effort
|
||||
{displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '}
|
||||
<Text color="subtle">← → to adjust</Text>
|
||||
<EffortLevelIndicator effort={displayEffort} /> {capitalize(displayEffort)} effort
|
||||
{displayEffort === focusedDefaultEffort ? ` (default)` : ``} <Text color="subtle">← → to adjust</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color="subtle">
|
||||
@@ -311,16 +287,14 @@ export function ModelPicker({
|
||||
showFastModeNotice ? (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Fast mode is <Text bold>ON</Text> and available with{' '}
|
||||
{FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other
|
||||
models turn off fast mode.
|
||||
Fast mode is <Text bold>ON</Text> and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching
|
||||
to other models turn off fast mode.
|
||||
</Text>
|
||||
</Box>
|
||||
) : isFastModeAvailable() && !isFastModeCooldown() ? (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Use <Text bold>/fast</Text> to turn on Fast mode (
|
||||
{FAST_MODE_MODEL_DISPLAY} only).
|
||||
Use <Text bold>/fast</Text> to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only).
|
||||
</Text>
|
||||
</Box>
|
||||
) : null
|
||||
@@ -334,68 +308,45 @@ export function ModelPicker({
|
||||
) : (
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<ConfigurableShortcutHint
|
||||
action="select:cancel"
|
||||
context="Select"
|
||||
fallback="Esc"
|
||||
description="exit"
|
||||
/>
|
||||
<ConfigurableShortcutHint action="select:cancel" context="Select" fallback="Esc" description="exit" />
|
||||
</Byline>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!isStandaloneCommand) {
|
||||
return content
|
||||
return content;
|
||||
}
|
||||
|
||||
return <Pane color="permission">{content}</Pane>
|
||||
return <Pane color="permission">{content}</Pane>;
|
||||
}
|
||||
|
||||
function resolveOptionModel(value?: string): string | undefined {
|
||||
if (!value) return undefined
|
||||
return value === NO_PREFERENCE
|
||||
? getDefaultMainLoopModel()
|
||||
: parseUserSpecifiedModel(value)
|
||||
if (!value) return undefined;
|
||||
return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value);
|
||||
}
|
||||
|
||||
function EffortLevelIndicator({
|
||||
effort,
|
||||
}: {
|
||||
effort?: EffortLevel
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Text color={effort ? 'claude' : 'subtle'}>
|
||||
{effortLevelToSymbol(effort ?? 'low')}
|
||||
</Text>
|
||||
)
|
||||
function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode {
|
||||
return <Text color={effort ? 'claude' : 'subtle'}>{effortLevelToSymbol(effort ?? 'low')}</Text>;
|
||||
}
|
||||
|
||||
function cycleEffortLevel(
|
||||
current: EffortLevel,
|
||||
direction: 'left' | 'right',
|
||||
includeMax: boolean,
|
||||
): EffortLevel {
|
||||
const levels: EffortLevel[] = includeMax
|
||||
? ['low', 'medium', 'high', 'max']
|
||||
: ['low', 'medium', 'high']
|
||||
function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel {
|
||||
const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high'];
|
||||
// If the current level isn't in the cycle (e.g. 'max' after switching to a
|
||||
// non-Opus model), clamp to 'high'.
|
||||
const idx = levels.indexOf(current)
|
||||
const currentIndex = idx !== -1 ? idx : levels.indexOf('high')
|
||||
const idx = levels.indexOf(current);
|
||||
const currentIndex = idx !== -1 ? idx : levels.indexOf('high');
|
||||
if (direction === 'right') {
|
||||
return levels[(currentIndex + 1) % levels.length]!
|
||||
return levels[(currentIndex + 1) % levels.length]!;
|
||||
} else {
|
||||
return levels[(currentIndex - 1 + levels.length) % levels.length]!
|
||||
return levels[(currentIndex - 1 + levels.length) % levels.length]!;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultEffortLevelForOption(value?: string): EffortLevel {
|
||||
const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel()
|
||||
const defaultValue = getDefaultEffortForModel(resolved)
|
||||
return defaultValue !== undefined
|
||||
? convertEffortValueToLevel(defaultValue)
|
||||
: 'high'
|
||||
const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel();
|
||||
const defaultValue = getDefaultEffortForModel(resolved);
|
||||
return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user