本章总览
hooks/notifs/ 子目录(17 个文件)是 REPL 顶部横幅通知的「传感器层」:每个 Hook 监听一种产品状态(MCP 失败、settings 校验、rate limit、插件安装…),调用 context/notifications.tsx 的 addNotification 写入 AppState 队列。它们不渲染 UI——NotificationBanner 组件读取 notifications.current 显示。本章要求你理解 priority / key / fold / invalidates 契约,以及 useStartupNotification 如何统一 remote-mode 与 once-per-session 守卫。
学完本章你应该能
- 解释 Notification 联合类型(text vs jsx)与 priority 四级
- 说明 addNotification 对 immediate 与非 immediate 的不同路径
- 列举 REPL.tsx import 的主要 notifs Hook 及其触发条件
- 理解 useStartupNotification 的 compute 函数与 async 错误处理
- 分析 useMcpConnectivityStatus 如何分 local vs claude.ai 四类通知
- 能在 useSettingsErrors 中看到 removeNotification 的「问题消失即撤横幅」模式
核心概念(先读懂这些)
key 是幂等与更新的锚点
同一 key 的通知通常表示「同一类状态」。settings-errors 在 errors 清空时 removeNotification;MCP 失败用固定 key mcp-failed 便于重复 mount 时 fold 或替换。immediate priority 会打断当前显示并清 timeout,适合 limit-reached 等必须立刻看到的告警。
remote mode 统一静默
多数 notifs Hook 首行检查 getIsRemoteMode()——远程/headless 会话无 Ink 横幅,避免无 UI 时写 AppState。useSettingsErrors、useMcpConnectivityStatus、useRateLimitWarningNotification 等均遵循此模式。
零 UI Hook 模式
notifs/ 下 Hook 返回值多为 void 或少量状态(如 useSettingsErrors 返回 errors 供 Doctor 复用)。REPL 主体 JSX 只需在根部调用一遍 useInstallMessages() 等副作用 Hook,无需传 props。新增通知类型时复制「useEffect + addNotification + key」模式,优先复用 useStartupNotification 若仅 mount 时跑一次。
建议学习步骤
- 阅读 context/notifications.tsx 类型与 addNotification 分支
- 读 useStartupNotification 注释与 hasRunRef 守卫
- 打开 useSettingsErrors 看 settings 变更订阅
- 分析 useMcpConnectivityStatus 四个 filter 与四类 key
- 浏览 REPL.tsx 中 notifs import 列表
- 对照 useCanUseTool deny 分支的 addNotification 理解跨模块写入
常见误区
注意
addNotification immediate 路径必须 return,否则会双入队
注意
fold 回调需返回带 fold 字段的 merged 对象以支持连续合并
注意
useMcpConnectivityStatus 对 claude.ai 仅通知「曾经连通过」的 failed/needs-auth,避免 nag 新 connector
注意
不要把 utils/hooks.ts Shell Hook 与 notifs Hook 混淆——后者只管 UI 横幅
通知子系统在架构中的位置
各业务 Hook(notifs/*、useCanUseTool、PromptInput…)
→ useNotifications().addNotification / removeNotification
↓
AppState.notifications { current, queue }
↓
processQueue(priority + timeoutMs 默认 8000ms)
↓
NotificationBanner / Ink Text 渲染 current
REPL.tsx 在组件体顶部集中调用十余个 notifs Hook——它们彼此独立,仅共享 notifications context。Doctor.tsx 复用 useSettingsErrors 获取 errors 数组,说明同一 Hook 可同时服务横幅与别的 UI。
Notification 类型与 addNotification 核心逻辑
类型:
TextNotification— text + 可选 color(Theme key)JSXNotification— jsx ReactNode(如 MCP 横幅带 dimColor 的 · /mcp 后缀)
Base 字段: key、priority(low|medium|high|immediate)、timeoutMs、invalidates[]、fold()
immediate 路径:
- 清 currentTimeoutId
- 立刻 setAppState current = notif
- 旧 current 与非 immediate 队列项重新入队(被 invalidates 的 key 除外)
- return — 不走普通入队
非 immediate 路径:
- 若 notif.fold 且 key 与 current 或 queue 中某项相同,执行 fold 合并并重置 timeout
- 否则按 priority 插入 queue,processQueue 在 current 为空时取出下一项
模块级 currentTimeoutId 保证同一时间只有一个 auto-dismiss 计时器。
源码引用: src/context/notifications.tsx · 第 5–34 行(共 313 行)
5|
6| type Priority = 'low' | 'medium' | 'high' | 'immediate'
7|
8| type BaseNotification = {
9| key: string
10| /**
11| * Keys of notifications that this notification invalidates.
12| * If a notification is invalidated, it will be removed from the queue
13| * and, if currently displayed, cleared immediately.
14| */
15| invalidates?: string[]
16| priority: Priority
17| timeoutMs?: number
18| /**
19| * Combine notifications with the same key, like Array.reduce().
20| * Called as fold(accumulator, incoming) when a notification with a matching
21| * key already exists in the queue or is currently displayed.
22| * Returns the merged notification (should carry fold forward for future merges).
23| */
24| fold?: (accumulator: Notification, incoming: Notification) => Notification
25| }
26|
27| type TextNotification = BaseNotification & {
28| text: string
29| color?: keyof Theme
30| }
31|
32| type JSXNotification = BaseNotification & {
33| jsx: React.ReactNode
34| }
源码引用: src/context/notifications.tsx · 第 78–117 行(共 313 行)
78| },
79| next.timeoutMs ?? DEFAULT_TIMEOUT_MS,
80| setAppState,
81| next.key,
82| processQueue,
83| )
84|
85| return {
86| ...prev,
87| notifications: {
88| queue: prev.notifications.queue.filter(_ => _ !== next),
89| current: next,
90| },
91| }
92| })
93| }, [setAppState])
94|
95| const addNotification = useCallback<AddNotificationFn>(
96| (notif: Notification) => {
97| // Handle immediate priority notifications
98| if (notif.priority === 'immediate') {
99| // Clear any existing timeout since we're showing a new immediate notification
100| if (currentTimeoutId) {
101| clearTimeout(currentTimeoutId)
102| currentTimeoutId = null
103| }
104|
105| // Set up timeout for the immediate notification
106| currentTimeoutId = setTimeout(
107| (setAppState, notif, processQueue) => {
108| currentTimeoutId = null
109| setAppState(prev => {
110| // Compare by key instead of reference to handle re-created notifications
111| if (prev.notifications.current?.key !== notif.key) {
112| return prev
113| }
114| return {
115| ...prev,
116| notifications: {
117| queue: prev.notifications.queue.filter(
源码引用: src/context/notifications.tsx · 第 119–164 行(共 313 行)
119| ),
120| current: null,
121| },
122| }
123| })
124| processQueue()
125| },
126| notif.timeoutMs ?? DEFAULT_TIMEOUT_MS,
127| setAppState,
128| notif,
129| processQueue,
130| )
131|
132| // Show the immediate notification right away
133| setAppState(prev => ({
134| ...prev,
135| notifications: {
136| current: notif,
137| queue:
138| // Only re-queue the current notification if it's not immediate
139| [
140| ...(prev.notifications.current
141| ? [prev.notifications.current]
142| : []),
143| ...prev.notifications.queue,
144| ].filter(
145| _ =>
146| _.priority !== 'immediate' &&
147| !notif.invalidates?.includes(_.key),
148| ),
149| },
150| }))
151| return // IMPORTANT: Exit addNotification for immediate notifications
152| }
153|
154| // Handle non-immediate notifications
155| setAppState(prev => {
156| // Check if we can fold into an existing notification with the same key
157| if (notif.fold) {
158| // Fold into current notification if keys match
159| if (prev.notifications.current?.key === notif.key) {
160| const folded = notif.fold(prev.notifications.current, notif)
161| // Reset timeout for the folded notification
162| if (currentTimeoutId) {
163| clearTimeout(currentTimeoutId)
164| currentTimeoutId = null
useStartupNotification:mount 一次模板
hooks/notifs/ 里大量 Hook 只需在 会话首次 mount 时发通知(安装检查结果、迁移提示等)。useStartupNotification(compute) 封装:
getIsRemoteMode()→ 跳过hasRunRef→ 只跑一次Promise.resolve().then(() => computeRef.current())— sync/async 皆可- result 为 null 跳过;Notification 或 Notification[] 逐个 addNotification
- reject → logError
compute 通过 ref 持有最新闭包,但 effect 只跑一次——compute 内不应依赖后续变化的 props(除非故意只读 mount 快照)。
useInstallMessages 是典型 consumer:async checkInstall(),按 message.type 映射 priority(error/userActionRequired → high,path/alias → medium,其余 low)与 color。
源码引用: src/hooks/notifs/useStartupNotification.ts · 第 11–41 行(共 42 行)
11| /**
12| * Fires notification(s) once on mount. Encapsulates the remote-mode gate and
13| * once-per-session ref guard that was hand-rolled across 10+ notifs/ hooks.
14| *
15| * The compute fn runs exactly once on first effect. Return null to skip,
16| * a Notification to fire one, or an array to fire several. Sync or async.
17| * Rejections are routed to logError.
18| */
19| export function useStartupNotification(
20| compute: () => Result | Promise<Result>,
21| ): void {
22| const { addNotification } = useNotifications()
23| const hasRunRef = useRef(false)
24| const computeRef = useRef(compute)
25| computeRef.current = compute
26|
27| useEffect(() => {
28| if (getIsRemoteMode() || hasRunRef.current) return
29| hasRunRef.current = true
30|
31| void Promise.resolve()
32| .then(() => computeRef.current())
33| .then(result => {
34| if (!result) return
35| for (const n of Array.isArray(result) ? result : [result]) {
36| addNotification(n)
37| }
38| })
39| .catch(logError)
40| }, [addNotification])
41| }
源码引用: src/hooks/notifs/useInstallMessages.tsx · 第 1–23 行(共 23 行)
1| import { checkInstall } from 'src/utils/nativeInstaller/index.js'
2| import { useStartupNotification } from './useStartupNotification.js'
3|
4| export function useInstallMessages(): void {
5| useStartupNotification(async () => {
6| const messages = await checkInstall()
7| return messages.map((message, index) => {
8| let priority: 'low' | 'medium' | 'high' | 'immediate' = 'low'
9| if (message.type === 'error' || message.userActionRequired) {
10| priority = 'high'
11| } else if (message.type === 'path' || message.type === 'alias') {
12| priority = 'medium'
13| }
14| return {
15| key: `install-message-${index}-${message.type}`,
16| text: message.message,
17| priority,
18| color: message.type === 'error' ? 'error' : 'warning',
19| }
20| })
21| })
22| }
23|
useSettingsErrors:持久态横幅 + Doctor 复用
useSettingsErrors 模式代表 「状态存在则显示,消失则 remove」:
- useState 初始值来自 getSettingsWithAllErrors()
- useSettingsChange 回调刷新 errors
- useEffect:remote mode 跳过;errors.length > 0 则 addNotification(key: settings-errors,warning,high,60s timeout);否则 removeNotification
返回 errors 数组供 Doctor 页面展示详情,横幅仅提示「N settings issues · /doctor」。
对比 startup 类 Hook: settings 错误可能用户修复 settings 文件后消失,必须 removeNotification 而非等 timeout。
源码引用: src/hooks/notifs/useSettingsErrors.tsx · 第 8–42 行(共 42 行)
8| const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors'
9|
10| export function useSettingsErrors(): ValidationError[] {
11| const { addNotification, removeNotification } = useNotifications()
12| const [errors, setErrors] = useState<ValidationError[]>(() => {
13| const { errors } = getSettingsWithAllErrors()
14| return errors
15| })
16|
17| const handleSettingsChange = useCallback(() => {
18| const { errors } = getSettingsWithAllErrors()
19| setErrors(errors)
20| }, [])
21|
22| useSettingsChange(handleSettingsChange)
23|
24| useEffect(() => {
25| if (getIsRemoteMode()) return
26| if (errors.length > 0) {
27| const message = `Found ${errors.length} settings ${errors.length === 1 ? 'issue' : 'issues'} · /doctor for details`
28| addNotification({
29| key: SETTINGS_ERRORS_NOTIFICATION_KEY,
30| text: message,
31| color: 'warning',
32| priority: 'high',
33| timeoutMs: 60000,
34| })
35| } else {
36| removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY)
37| }
38| }, [errors, addNotification, removeNotification])
39|
40| return errors
41| }
42|
useMcpConnectivityStatus:四分桶 MCP 告警
传入 mcpClients(默认 []),useEffect 内过滤四类:
| 过滤 | key | 颜色 |
|---|---|---|
| local failed(排除 sse-ide/ws-ide/claudeai-proxy) | mcp-failed | error |
| claude.ai failed 且 hasClaudeAiMcpEverConnected | mcp-claudeai-failed | error |
| local needs-auth | mcp-needs-auth | warning |
| claude.ai needs-auth 且曾连接 | mcp-claudeai-needs-auth | warning |
注释解释 claude.ai 策略:全新 connector 的 needs-auth 不 nag;曾经连上现在失败才 worth 横幅(状态变化 worthy)。
jsx 通知用 Ink Text 组合主文案 + dim 「· /mcp」引导用户进入管理面板。依赖 useMergedClients 提供的合并客户端列表。
源码引用: src/hooks/notifs/useMcpConnectivityStatus.tsx · 第 12–75 行(共 127 行)
12|
13| const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []
14|
15| export function useMcpConnectivityStatus({
16| mcpClients = EMPTY_MCP_CLIENTS,
17| }: Props): void {
18| const { addNotification } = useNotifications()
19| useEffect(() => {
20| if (getIsRemoteMode()) return
21| const failedLocalClients = mcpClients.filter(
22| client =>
23| client.type === 'failed' &&
24| client.config.type !== 'sse-ide' &&
25| client.config.type !== 'ws-ide' &&
26| client.config.type !== 'claudeai-proxy',
27| )
28| // claude.ai failures get a separate notification: they almost always indicate
29| // a toolbox-service outage (shared auth backend), not a local config issue.
30| // Only flag connectors that have previously connected successfully — an
31| // org-configured connector that's been needs-auth since it appeared is one
32| // the user has ignored and shouldn't nag about; one that was working
33| // yesterday and is now failed is a state change worth surfacing.
34| const failedClaudeAiClients = mcpClients.filter(
35| client =>
36| client.type === 'failed' &&
37| client.config.type === 'claudeai-proxy' &&
38| hasClaudeAiMcpEverConnected(client.name),
39| )
40| const needsAuthLocalServers = mcpClients.filter(
41| client =>
42| client.type === 'needs-auth' && client.config.type !== 'claudeai-proxy',
43| )
44| const needsAuthClaudeAiServers = mcpClients.filter(
45| client =>
46| client.type === 'needs-auth' &&
47| client.config.type === 'claudeai-proxy' &&
48| hasClaudeAiMcpEverConnected(client.name),
49| )
50| if (
51| failedLocalClients.length === 0 &&
52| failedClaudeAiClients.length === 0 &&
53| needsAuthLocalServers.length === 0 &&
54| needsAuthClaudeAiServers.length === 0
55| ) {
56| return
57| }
58| if (failedLocalClients.length > 0) {
59| addNotification({
60| key: 'mcp-failed',
61| jsx: (
62| <>
63| <Text color="error">
64| {failedLocalClients.length} MCP{' '}
65| {failedLocalClients.length === 1 ? 'server' : 'servers'} failed
66| </Text>
67| <Text dimColor> · /mcp</Text>
68| </>
69| ),
70| priority: 'medium',
71| })
72| }
73| if (failedClaudeAiClients.length > 0) {
74| addNotification({
75| key: 'mcp-claudeai-failed',
源码引用: src/hooks/notifs/useMcpConnectivityStatus.tsx · 第 76–87 行(共 127 行)
76| jsx: (
77| <>
78| <Text color="error">
79| {failedClaudeAiClients.length} claude.ai{' '}
80| {failedClaudeAiClients.length === 1 ? 'connector' : 'connectors'}{' '}
81| unavailable
82| </Text>
83| <Text dimColor> · /mcp</Text>
84| </>
85| ),
86| priority: 'medium',
87| })
useRateLimitWarningNotification 与 immediate 优先级
rate limit / overage 类通知使用 priority: immediate,打断当前横幅立刻显示 limit-reached。
逻辑要点:
- useClaudeAiLimits() 提供配额状态
- getRateLimitWarning / getUsingOverageText 生成文案
- isUsingOverage 时 addNotification immediate;恢复后 reset hasShownOverageNotification
- team/enterprise 需 hasClaudeAiBillingAccess 才显示 overage
immediate 与 settings-errors(high 但非 immediate)对比,理解何时抢焦点 vs 排队。
源码引用: src/hooks/notifs/useRateLimitWarningNotification.tsx · 第 11–74 行(共 81 行)
11| import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'
12| import { getIsRemoteMode } from '../../bootstrap/state.js'
13|
14| export function useRateLimitWarningNotification(model: string): void {
15| const { addNotification } = useNotifications()
16| const claudeAiLimits = useClaudeAiLimits()
17| // claudeAiLimits reference is stable until statusListeners fire (API
18| // response), so these skip the Intl formatting work on most REPL renders.
19| const rateLimitWarning = useMemo(
20| () => getRateLimitWarning(claudeAiLimits, model),
21| [claudeAiLimits, model],
22| )
23| const usingOverageText = useMemo(
24| () => getUsingOverageText(claudeAiLimits),
25| [claudeAiLimits],
26| )
27| const shownWarningRef = useRef<string | null>(null)
28| const subscriptionType = getSubscriptionType()
29| const hasBillingAccess = hasClaudeAiBillingAccess()
30| const isTeamOrEnterprise =
31| subscriptionType === 'team' || subscriptionType === 'enterprise'
32|
33| // Track overage mode transitions
34| const [hasShownOverageNotification, setHasShownOverageNotification] =
35| useState(false)
36|
37| // Show immediate notification when entering overage mode
38| useEffect(() => {
39| if (getIsRemoteMode()) return
40| if (
41| claudeAiLimits.isUsingOverage &&
42| !hasShownOverageNotification &&
43| (!isTeamOrEnterprise || hasBillingAccess)
44| ) {
45| addNotification({
46| key: 'limit-reached',
47| text: usingOverageText,
48| priority: 'immediate',
49| })
50| setHasShownOverageNotification(true)
51| } else if (!claudeAiLimits.isUsingOverage && hasShownOverageNotification) {
52| // Reset when no longer in overage mode
53| setHasShownOverageNotification(false)
54| }
55| }, [
56| claudeAiLimits.isUsingOverage,
57| usingOverageText,
58| hasShownOverageNotification,
59| addNotification,
60| hasBillingAccess,
61| isTeamOrEnterprise,
62| ])
63|
64| // Show warning notification for approaching limits
65| useEffect(() => {
66| if (getIsRemoteMode()) return
67| if (rateLimitWarning && rateLimitWarning !== shownWarningRef.current) {
68| shownWarningRef.current = rateLimitWarning
69| addNotification({
70| key: 'rate-limit-warning',
71| jsx: (
72| <Text>
73| <Text color="warning">{rateLimitWarning}</Text>
74| </Text>
REPL 挂载的 notifs Hook 清单
REPL.tsx 导入的主要传感器(按产品域分组):
安装与环境: useInstallMessages、useNpmDeprecationNotification、useDeprecationWarningNotification
账户与限额: useRateLimitWarningNotification、useCanSwitchToExistingSubscription、useAutoModeUnavailableNotification、useFastModeNotification
MCP / IDE / LSP: useMcpConnectivityStatus、useIDEStatusIndicator、useLspInitializationNotification
插件: usePluginInstallationStatus、usePluginAutoupdateNotification
设置与迁移: useSettingsErrors、useModelMigrationNotifications
协作: useTeammateLifecycleNotification(teammate shutdown)、useAntOrgWarningNotification(ANT 构建专用)
每个 Hook 文件通常 <100 行,模式高度一致。读一个即可类推其余;差异在业务判断与 key 命名。
源码引用: src/screens/REPL.tsx · 第 232–267 行(共 7050 行)
232| applyPermissionUpdate,
233| applyPermissionUpdates,
234| persistPermissionUpdate,
235| } from '../utils/permissions/PermissionUpdate.js'
236| import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'
237| import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'
238| import {
239| getScratchpadDir,
240| isScratchpadEnabled,
241| } from '../utils/permissions/filesystem.js'
242| import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'
243| import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'
244| import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'
245| import type { AutoUpdaterResult } from '../utils/autoUpdater.js'
246| import {
247| getGlobalConfig,
248| saveGlobalConfig,
249| getGlobalConfigWriteCount,
250| } from '../utils/config.js'
251| import { hasConsoleBillingAccess } from '../utils/billing.js'
252| import {
253| logEvent,
254| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
255| } from 'src/services/analytics/index.js'
256| import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
257| import {
258| textForResubmit,
259| handleMessageFromStream,
260| type StreamingToolUse,
261| type StreamingThinking,
262| isCompactBoundaryMessage,
263| getMessagesAfterCompactBoundary,
264| getContentText,
265| createUserMessage,
266| createAssistantMessage,
267| createTurnDurationMessage,
与其他模块的交叉写入
notifs 队列不只 hooks/notifs/ 写入:
- useCanUseTool deny 分支:auto-mode classifier 拒绝时 addNotification 提示 /permissions
- PromptInput:think/ultraplan trigger、粘贴过大等场景 addNotification
- query/stopHooks:Stop hook 失败可选 addNotification
- buddy/useBuddyNotification:Buddy 功能专用横幅
调试「横幅从哪来」时,全局搜 addNotification({ key: 并对照 key 字符串。hooks/notifs/ 应使用稳定、文档化的 key; ad-hoc 通知也应用 key 便于去重。
源码目录(notifs 子目录)
核心基础设施:context/notifications.tsx。渲染侧在 components 中搜索 NotificationBanner 或 notifications.current。
动手练习
- 故意写错 settings.json,确认 settings-errors 横幅出现;修复后横幅消失
- 断开 MCP server,观察 mcp-failed jsx 文案与 /mcp 引导
- 在 addNotification 设 breakpoint,区分 immediate 与普通入队路径
- 阅读 usePluginAutoupdateNotification,对比 useStartupNotification 与 useEffect 周期检查差异
- 对照 useCanUseTool deny 通知,画出权限拒绝到横幅的完整链
本章小结与延伸
notifs Hook = 把分散的产品状态翻译成统一 Notification 队列。下一章可回到 useCanUseTool,看 deny 分支如何 addNotification 提示 /permissions。 继续学习: