第三十四章:AppState 为什么是 UI 与 runtime 的接缝层
把 AppState 当成宿主边界状态容器来读,拆 UI 投影、runtime 注册表和副作用桥。
1. 为什么这章必须单独拆
前面的文档已经反复提到 AppState,但一直没有把它当成一个正式的边界来讲。
这会带来一个很大的误判:
- 很容易把它看成普通 React UI state
- 也很容易反过来,把它当成“所有状态都往里塞”的大对象
而从源码看,AppState 其实处在 Claude Code 最尴尬、也最值钱的一条边界线上:
- 一头连着 REPL、Ink、各种面板和交互投影
- 一头连着 task、agent、MCP、plugin、remote bridge、权限模式这些 runtime 子系统
- 中间还夹着 settings 持久化、session metadata 同步、环境变量重载这些副作用
如果不把这条线拆清楚,后面做 C# 版时最容易抄出一个巨大的 God Object。
相关源码锚点:
src/state/AppStateStore.tssrc/state/AppState.tsxsrc/state/onChangeAppState.tssrc/state/selectors.tssrc/screens/REPL.tsxsrc/components/App.tsx
2. 先说结论
我对这一块的判断是:
Claude Code 里的 AppState 不是纯 UI state,也不是纯 session core state,而是一个“宿主边界状态容器”。
它大致同时装了四类东西:
- 会话核心配置
比如
settings、mainLoopModel、toolPermissionContext、thinkingEnabled - 运行时注册表
比如
tasks、agentNameRegistry、mcp、plugins - 宿主投影状态
比如
expandedView、footerSelection、viewSelectionMode - 临时工作流状态
比如
promptSuggestion、speculation、activeOverlays、pendingPlanVerification
这里最关键的设计点有三个:
AppStateStore更像运行时 store,不像 React component state。onChangeAppState()是真正的边界桥,不只是 setState 回调。- 当前实现是刻意混合层,值钱的地方不在“照抄”,而在“拆分准则”。
3. 总体结构图
flowchart TD
A["Query / Task / MCP / Plugin / Remote 子系统"] --> B["AppStateStore"]
C["REPL / 组件 / 面板 / 状态条"] --> B
B --> D["selector + useSyncExternalStore"]
D --> C
B --> E["onChangeAppState"]
E --> F["settings 持久化"]
E --> G["session metadata / permission mode 通知"]
E --> H["env / auth cache reset"]
E --> I["tmux / 宿主状态同步"]
这张图最想说明的是:
AppState 不是“UI 订阅 runtime”,而是 UI 和 runtime 共用同一个边界容器。
4. AppStateStore 里到底混了什么
源码锚点:
src/state/AppStateStore.ts
getDefaultAppState() 已经把这层的真实语义暴露得很明显了。
4.1 会话核心配置
这里装的是当前会话真正会影响执行面的设置,例如:
settingsmainLoopModeltoolPermissionContextverbosethinkingEnabledpromptSuggestionEnabledauthVersion
这些字段并不是“界面怎么画”,而是会直接改模型调用、权限判断和行为策略。
4.2 运行时注册表
这部分更像 runtime registry:
tasksforegroundedTaskIdviewingAgentTaskIdagentNameRegistrytodosmcp.clients / tools / commands / resourcesplugins.enabled / disabled / errors / installationStatus / needsRefresh
这些状态的共同点是:
- 会被多个子系统读写
- 会驱动 UI 展示
- 但本质不属于 UI
4.3 宿主投影状态
也有一批字段明显只是宿主层投影:
expandedViewfooterSelectionviewSelectionModeselectedIPAgentIndexshowRemoteCalloutstatusLineText
这些字段如果以后换成 IDE 宿主、Web 宿主,很多都不该进入 session core。
4.4 临时工作流状态
还有一些状态带着明显的“流程编排”味道:
sessionHookspromptSuggestionspeculationactiveOverlayspendingPlanVerificationdenialTracking
这类字段很容易在长期演化里把 store 越养越胖。
但反过来看,它也说明一件事:
Claude Code 习惯把“需要跨组件、跨子系统一起观察的临时流程状态”挂到统一 store,而不是藏进某个组件本地 hook。
5. 读写链路:为什么它不像普通 React state
源码锚点:
src/state/AppState.tsxsrc/state/selectors.ts
AppStateProvider 并没有用常见的 useReducer + context value 全量往下灌。
它做的是:
- 只创建一次 store
- 用 selector 订阅切片
- 基于
useSyncExternalStore提供稳定订阅语义
这代表作者已经默认把它当成:
- 需要多方读写
- 不能因为一次无关更新就全树重渲染
- 要和 React 生命周期适度解耦
换句话说,这里的主角不是 React,而是 store。
React 只是一个观察者宿主。
6. onChangeAppState() 才是真正的边界桥
源码锚点:
src/state/onChangeAppState.ts
如果只看名字,onChangeAppState() 很容易被误会成一个普通监听器。
但它其实承担了几类关键副作用:
6.1 把 runtime 选择写回外部世界
例如:
mainLoopModel变化时写回 settings 和 bootstrap overrideexpandedView、verbose变化时写回全局配置
6.2 把权限模式同步给外部会话协议
比如:
- 调
notifySessionMetadataChanged() - 调
notifyPermissionModeChanged()
这说明权限模式不是 UI 自己的 toggle,而是 session metadata 的一部分。
6.3 在 settings 变化时重绑运行时
例如:
- 清理 auth 相关 cache
- 重新应用环境变量
也就是说,settings 不是“显示偏好”而已,它会触发 runtime rebinding。
7. 这块最值钱的地方:它揭示了 Claude Code 的宿主边界
如果把 AppState 只看成“做得不够干净”,会错过真正值钱的东西。
它其实回答了一个更重要的问题:
Claude Code 认为什么东西需要被宿主实时观察和操控。
现在被放进 AppState 的,通常满足至少一个条件:
- 要直接投影到 REPL
- 要被命令、工具、插件或 bridge 共同读写
- 改动后需要立刻触发副作用
- 当前还没有更稳定的专用边界
所以它不是随便长出来的“大对象”,而是历史演化下的“统一接缝层”。
8. 对 C# 拆分最有用的建议
这一章最不建议做的事,就是照着把整个 AppState 原样翻译成一个大 C# record。
更合理的拆法应该至少分四块:
8.1 SessionCoreState
放真正影响 agent 行为的字段:
- effective settings
- model selection
- permission mode
- auth version
8.2 RuntimeRegistries
放会被多个 runtime 子系统共享的注册表:
- tasks
- agents
- mcp
- plugins
8.3 HostProjectionState
只给 CLI / IDE / Web 宿主看的投影层:
- panel selection
- expanded view
- status line
- overlay visibility
8.4 StateSideEffectBridge
专门处理副作用:
- settings persistence
- session metadata notify
- env reload
- cache invalidation
9. 哪些值得抄,哪些不要抄
值得抄的:
- selector + 外部 store 的组织方式
- 把状态变化副作用集中到桥接层
- 把 UI 投影和 runtime 共享态放进同一观察面
不要原样抄的:
- 过多临时流程状态长期滞留在总 store
- 宿主状态和核心状态没有显式类型边界
- 让一个对象既负责存状态,又隐式代表一堆跨子系统协议
10. 一句话收口
AppState 真正值钱的地方,不是它装了多少字段,而是它暴露了 Claude Code 把 UI、宿主和 runtime 接在一起的那条真实接缝。