本章总览
/model 是 Claude Code 最常用的 local-jsx 命令之一:无参数时弹出 ModelPicker 菜单;带参数时 inline 设置 AppState.mainLoopModel;--info / --help 变体显示当前模型或用法提示。实现分散在 commands/model/ 与 utils/model/、components/ModelPicker。本章追踪校验链(allowlist、1M access、validateModel)与 fast mode / extra usage 联动。
学完本章你应该能
- 说明 index.ts 元数据与 model.tsx call 的三路分支
- 解释 ModelPickerWrapper handleSelect 如何写 state 与 analytics
- 描述 SetModelAndClose 的 alias / validateModel / 1M 门禁
- 理解 fastMode 与 isBilledAsExtraUsage 的自动降级
- 知道 bridge 模式下 /model 被阻断的原因
核心概念(先读懂这些)
mainLoopModel vs mainLoopModelForSession
Plan mode 等场景可设 mainLoopModelForSession 作为 session 级 override;ShowModelAndClose 会同时展示 base model 与 session override。handleSelect 与 setModel 均将 mainLoopModelForSession 置 null,表示用户手动选择覆盖 plan 临时模型。
immediate 与 inference config
index.ts 的 immediate: shouldInferenceConfigCommandBeImmediate() 使 /model 在部分配置下跳过输入队列立即执行——与 /effort 等 inference 相关命令一致,减少「改了模型但要等 turn 结束」的延迟。
validateModel 不 lowercases 非 alias
SetModelAndClose 注释强调:非 alias 模型名 case-sensitive,不能用 parseUserSpecifiedModel(会 lowercase)。已知 alias 走 MODEL_ALIASES 快速路径;其余 async validateModel API 探测。
建议学习步骤
- 阅读源码块 A:index.ts Command 定义
- 阅读源码块 B:call 三路分支
- 阅读源码块 C:ModelPickerWrapper handleSelect
- 阅读源码块 D:SetModelAndClose 校验链
- 阅读源码块 E:1M access 与 allowlist 检查
- 阅读源码块 F:ShowModelAndClose 信息展示
- 在 ModelPicker 组件对照 effort 与 fast mode UI
常见误区
注意
default 参数设 model=null 表示恢复 settings 默认,非 API default 字符串
注意
fast mode 自动关闭时不写 settings(仅 session state)
注意
组织 allowlist 拒绝时 display:system,不抛异常
在架构中的位置
模型选择影响整条 query 链:
/model sonnet → setAppState({ mainLoopModel })
→ REPL 下次 query 读 mainLoopModel
→ query.ts → getMainLoopModel() → claude.ts normalizeModelStringForAPI
→ cost-tracker / usage 按新 model 计价
/model 不直接调 API;只改 state 与 settings 持久化(经 setAppState 副作用)。components/ModelPicker 也被 status bar、onboarding 复用;isStandaloneCommand={true} 区分全屏命令菜单与内嵌 picker。
Bridge/mobile:isBridgeSafeCommand 对 local-jsx 返回 false,故 /model 不能从 Remote Control 触发——避免远端弹本地 Ink UI。
index.ts:Command 元数据
commands/model/index.ts 导出 satisfies Command 的默认对象:
type: 'local-jsx'name: 'model'- getter description:动态嵌入
renderModelName(getMainLoopModel()),typeahead 始终显示当前模型 argumentHint: '[model]'- getter immediate:
shouldInferenceConfigCommandBeImmediate() load: () => import('./model.js')— 编译后 .tsx → .js
这种 getter 模式让 help 文本随 session 刷新,无需 clearCommandsCache。
源码引用: src/commands/model/index.ts · 第 1–16 行(共 17 行)
1| import type { Command } from '../../commands.js'
2| import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
3| import { getMainLoopModel, renderModelName } from '../../utils/model/model.js'
4|
5| export default {
6| type: 'local-jsx',
7| name: 'model',
8| get description() {
9| return `Set the AI model for Claude Code (currently ${renderModelName(getMainLoopModel())})`
10| },
11| argumentHint: '[model]',
12| get immediate() {
13| return shouldInferenceConfigCommandBeImmediate()
14| },
15| load: () => import('./model.js'),
16| } satisfies Command
call 入口:三路分支
model.tsx export call 是 LocalJSXCommandCall:
- COMMON_INFO_ARGS(如
--info)→<ShowModelAndClose />,logtengu_model_command_inline_help - COMMON_HELP_ARGS → onDone 静态 help 字符串,display:'system'
- args 非空 →
<SetModelAndClose args={args} />,logtengu_model_command_inline - 无 args →
<ModelPickerWrapper />全屏菜单
args trim 后匹配;default 在 SetModelAndClose 转为 null(恢复默认设置)。
renderModelLabel 用 renderDefaultModelSetting;null 时后缀 (default)。
源码引用: src/commands/model/model.tsx · 第 271–296 行(共 338 行)
271| }
272|
273| function isSonnet1mUnavailable(model: string): boolean {
274| const m = model.toLowerCase()
275| // Warn about Sonnet and Sonnet 4.6, but not Sonnet 4.5 since that had
276| // a different access criteria.
277| return (
278| !checkSonnet1mAccess() &&
279| (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'))
280| )
281| }
282|
283| function ShowModelAndClose({
284| onDone,
285| }: {
286| onDone: (result?: string) => void
287| }): React.ReactNode {
288| const mainLoopModel = useAppState(s => s.mainLoopModel)
289| const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
290| const effortValue = useAppState(s => s.effortValue)
291| const displayModel = renderModelLabel(mainLoopModel)
292| const effortInfo =
293| effortValue !== undefined ? ` (effort: ${effortValue})` : ''
294|
295| if (mainLoopModelForSession) {
296| onDone(
源码引用: src/commands/model/model.tsx · 第 293–296 行(共 338 行)
293| effortValue !== undefined ? ` (effort: ${effortValue})` : ''
294|
295| if (mainLoopModelForSession) {
296| onDone(
ModelPickerWrapper:菜单选择
菜单路径 handleSelect(model, effort?):
logEvent('tengu_model_command_menu', { action, from_model, to_model })setAppState({ mainLoopModel: model, mainLoopModelForSession: null })- 构建用户可见 message:model label + optional effort
- Fast mode 联动:
clearFastModeCooldown()on change- 新模型不支持 fast 且当前 fastMode → 设 fastMode:false,后缀「Fast mode OFF」
- 支持且 available → 后缀「Fast mode ON」
- isBilledAsExtraUsage → 后缀「Billed as extra usage」
handleCancel log cancel action,onDone「Kept model as …」display:system。
渲染 <ModelPicker initial={mainLoopModel} sessionModel={mainLoopModelForSession} isStandaloneCommand showFastModeNotice />。
源码引用: src/commands/model/model.tsx · 第 18–114 行(共 338 行)
18| isFastModeSupportedByModel,
19| } from '../../utils/fastMode.js'
20| import { MODEL_ALIASES } from '../../utils/model/aliases.js'
21| import {
22| checkOpus1mAccess,
23| checkSonnet1mAccess,
24| } from '../../utils/model/check1mAccess.js'
25| import {
26| getDefaultMainLoopModelSetting,
27| isOpus1mMergeEnabled,
28| renderDefaultModelSetting,
29| } from '../../utils/model/model.js'
30| import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
31| import { validateModel } from '../../utils/model/validateModel.js'
32|
33| function ModelPickerWrapper({
34| onDone,
35| }: {
36| onDone: (
37| result?: string,
38| options?: { display?: CommandResultDisplay },
39| ) => void
40| }): React.ReactNode {
41| const mainLoopModel = useAppState(s => s.mainLoopModel)
42| const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
43| const isFastMode = useAppState(s => s.fastMode)
44| const setAppState = useSetAppState()
45|
46| function handleCancel(): void {
47| logEvent('tengu_model_command_menu', {
48| action:
49| 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
50| })
51| const displayModel = renderModelLabel(mainLoopModel)
52| onDone(`Kept model as ${chalk.bold(displayModel)}`, {
53| display: 'system',
54| })
55| }
56|
57| function handleSelect(
58| model: string | null,
59| effort: EffortLevel | undefined,
60| ): void {
61| logEvent('tengu_model_command_menu', {
62| action:
63| model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
64| from_model:
65| mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
66| to_model:
67| model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
68| })
69| setAppState(prev => ({
70| ...prev,
71| mainLoopModel: model,
72| mainLoopModelForSession: null,
73| }))
74|
75| let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
76| if (effort !== undefined) {
77| message += ` with ${chalk.bold(effort)} effort`
78| }
79|
80| // Turn off fast mode if switching to unsupported model
81| let wasFastModeToggledOn = undefined
82| if (isFastModeEnabled()) {
83| clearFastModeCooldown()
84| if (!isFastModeSupportedByModel(model) && isFastMode) {
85| setAppState(prev => ({
86| ...prev,
87| fastMode: false,
88| }))
89| wasFastModeToggledOn = false
90| // Do not update fast mode in settings since this is an automatic downgrade
91| } else if (
92| isFastModeSupportedByModel(model) &&
93| isFastModeAvailable() &&
94| isFastMode
95| ) {
96| message += ` · Fast mode ON`
97| wasFastModeToggledOn = true
98| }
99| }
100|
101| if (
102| isBilledAsExtraUsage(
103| model,
104| wasFastModeToggledOn === true,
105| isOpus1mMergeEnabled(),
106| )
107| ) {
108| message += ` · Billed as extra usage`
109| }
110|
111| if (wasFastModeToggledOn === false) {
112| // Fast mode was toggled off, show suffix after extra usage billing
113| message += ` · Fast mode OFF`
114| }
SetModelAndClose:inline 设模型
React.useEffect 内 handleModelChange 异步链:
门禁顺序(fail-fast):
isModelAllowed(model)— 组织 allowlistisOpus1mUnavailable(model)— Opus 4.6 [1m] 账户权限isSonnet1mUnavailable(model)— Sonnet 4.6 [1m](不含 4.5 旧规则)model === null(default)→ setModel(null)isKnownAlias(model)→ setModel 跳过 API 校验- else
validateModel(model)async
setModel mirror 菜单路径的 fast mode / extra usage 逻辑,最后 onDone(message)。
1M 不可用错误含 docs 链接 model-config#extended-context-with-1m。
源码引用: src/commands/model/model.tsx · 第 130–231 行(共 338 行)
130| isFastModeAvailable()
131| }
132| />
133| )
134| }
135|
136| function SetModelAndClose({
137| args,
138| onDone,
139| }: {
140| args: string
141| onDone: (
142| result?: string,
143| options?: { display?: CommandResultDisplay },
144| ) => void
145| }): React.ReactNode {
146| const isFastMode = useAppState(s => s.fastMode)
147| const setAppState = useSetAppState()
148| const model = args === 'default' ? null : args
149|
150| React.useEffect(() => {
151| async function handleModelChange(): Promise<void> {
152| if (model && !isModelAllowed(model)) {
153| onDone(
154| `Model '${model}' is not available. Your organization restricts model selection.`,
155| { display: 'system' },
156| )
157| return
158| }
159|
160| // @[MODEL LAUNCH]: Update check for 1M access.
161| if (model && isOpus1mUnavailable(model)) {
162| onDone(
163| `Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
164| { display: 'system' },
165| )
166| return
167| }
168|
169| if (model && isSonnet1mUnavailable(model)) {
170| onDone(
171| `Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
172| { display: 'system' },
173| )
174| return
175| }
176|
177| // Skip validation for default model
178| if (!model) {
179| setModel(null)
180| return
181| }
182|
183| // Skip validation for known aliases - they're predefined and should work
184| if (isKnownAlias(model)) {
185| setModel(model)
186| return
187| }
188|
189| // Validate and set custom model
190| try {
191| // Don't use parseUserSpecifiedModel for non-aliases since it lowercases the input
192| // and model names are case-sensitive
193| const { valid, error } = await validateModel(model)
194|
195| if (valid) {
196| setModel(model)
197| } else {
198| onDone(error || `Model '${model}' not found`, {
199| display: 'system',
200| })
201| }
202| } catch (error) {
203| onDone(`Failed to validate model: ${(error as Error).message}`, {
204| display: 'system',
205| })
206| }
207| }
208|
209| function setModel(modelValue: string | null): void {
210| setAppState(prev => ({
211| ...prev,
212| mainLoopModel: modelValue,
213| mainLoopModelForSession: null,
214| }))
215| let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
216|
217| let wasFastModeToggledOn = undefined
218| if (isFastModeEnabled()) {
219| clearFastModeCooldown()
220| if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
221| setAppState(prev => ({
222| ...prev,
223| fastMode: false,
224| }))
225| wasFastModeToggledOn = false
226| // Do not update fast mode in settings since this is an automatic downgrade
227| } else if (isFastModeSupportedByModel(modelValue) && isFastMode) {
228| message += ` · Fast mode ON`
229| wasFastModeToggledOn = true
230| }
231| }
源码引用: src/commands/model/model.tsx · 第 233–245 行(共 338 行)
233| if (
234| isBilledAsExtraUsage(
235| modelValue,
236| wasFastModeToggledOn === true,
237| isOpus1mMergeEnabled(),
238| )
239| ) {
240| message += ` · Billed as extra usage`
241| }
242|
243| if (wasFastModeToggledOn === false) {
244| // Fast mode was toggled off, show suffix after extra usage billing
245| message += ` · Fast mode OFF`
ShowModelAndClose:查看当前模型
只读路径:读 mainLoopModel、mainLoopModelForSession、effortValue。
若存在 session override(plan mode):
Current model: <session> (session override from plan mode)
Base model: <base> (effort: x)
否则单行 Current model + effort 后缀。
组件 render 后立即 onDone,return null——典型 local-jsx「无 UI 一次性输出」模式。
源码引用: src/commands/model/model.tsx · 第 246–270 行(共 338 行)
246| }
247|
248| onDone(message)
249| }
250|
251| void handleModelChange()
252| }, [model, onDone, setAppState])
253|
254| return null
255| }
256|
257| function isKnownAlias(model: string): boolean {
258| return (MODEL_ALIASES as readonly string[]).includes(
259| model.toLowerCase().trim(),
260| )
261| }
262|
263| function isOpus1mUnavailable(model: string): boolean {
264| const m = model.toLowerCase()
265| return (
266| !checkOpus1mAccess() &&
267| !isOpus1mMergeEnabled() &&
268| m.includes('opus') &&
269| m.includes('[1m]')
270| )
依赖的 utils 与组件
model.tsx import 边界清晰:
| 模块 | 用途 |
|---|---|
| components/ModelPicker | 列表 UI、effort 选择 |
| utils/model/aliases | MODEL_ALIASES、isKnownAlias |
| utils/model/validateModel | 非 alias API 探测 |
| utils/model/modelAllowlist | isModelAllowed 组织策略 |
| utils/model/check1mAccess | Opus/Sonnet 1M entitlement |
| utils/fastMode | 支持检测、cooldown、available |
| utils/extraUsage | isBilledAsExtraUsage 计费提示 |
| constants/xml | COMMON_HELP_ARGS、COMMON_INFO_ARGS |
| services/analytics | tengu_model_command_* 事件 |
改模型列表或 entitlement 时,多数逻辑在 ModelPicker 与 utils/model,model.tsx 只做命令 glue。
源码引用: src/commands/model/model.tsx · 第 1–17 行(共 338 行)
1| import chalk from 'chalk'
2| import * as React from 'react'
3| import type { CommandResultDisplay } from '../../commands.js'
4| import { ModelPicker } from '../../components/ModelPicker.js'
5| import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
6| import {
7| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
8| logEvent,
9| } from '../../services/analytics/index.js'
10| import { useAppState, useSetAppState } from '../../state/AppState.js'
11| import type { LocalJSXCommandCall } from '../../types/command.js'
12| import type { EffortLevel } from '../../utils/effort.js'
13| import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
14| import {
15| clearFastModeCooldown,
16| isFastModeAvailable,
17| isFastModeEnabled,
相关命令:/effort /fast
Inference 配置命令族共享模式:
- /effort — 设 reasoning effort,与 model 正交
- /fast — toggle fastMode(GrowthBook + 模型支持 gated)
- /rate-limit-options、/extra-usage — 1P 计费相关,availability gated
/model 切换模型时可能自动关 fast mode,但不自动改 effort——effort 存 effortValue 独立字段。Plan mode session override 仅影响 mainLoopModelForSession,/model 用户选择会清除 override。
Telemetry:tengu_model_command_menu vs inline vs inline_help 三分事件,便于区分菜单与参数用法占比。
Settings 持久化经 setAppState reducer 链写入 disk cache;重启 session 后 description getter 仍反映上次选择。调试「模型未保存」时查 settings.json 与 bootstrap state latch 是否 mid-session 被 reset。
本章小结与延伸
/model = AppState 模型指针的 UX 入口。下一章 mcp-commands,读外部工具服务器管理命令。 继续学习: