ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenClaw 菜单栏状态逻辑深度解析:IconState、工作状态机与 Critter 图标驱动原理

OpenClaw 菜单栏状态逻辑深度解析:IconState、工作状态机与 Critter 图标驱动原理
  • 人工智能
  • AI Agent
  • 即时通讯
  • 后端
  • 本地部署
  • 语音

【免费下载链接】openclaw-cn

中文社区版OpenClaw,同原版保持定期更新,已内置钉钉、企业微信、飞书、QQ、微信以及国内网络环境优化。你的专属个人AI助手。支持所有操作系统和平台。🦞

项目地址:https://gitcode.com/gh_mirrors/op/openclaw-cn
点击查看免费下载

本文围绕 macOS 客户端 Clawdbot 的菜单栏(Menu Bar)状态体系展开,系统讲解菜单栏 Critter 图标如何根据 Agent 会话活动实时切换工作状态、工作徽标(Badge)与状态行文案的完整链路:从控制通道agent事件摄取、WorkActivityStore状态机裁决、IconState枚举建模,到图标渲染与调试覆盖机制。读完本文,你将掌握该菜单栏状态模块的完整数据流、会话优先级规则、活动类型映射与测试验证清单,可直接用于二次开发或问题排查。

一、菜单栏状态展示总览:什么会被“表面化”给用户

Clawdbot 的菜单栏(Menu Bar)承担着向用户即时反馈 Agent 工作状态的角色,其展示逻辑由三部分构成:

  • 菜单栏图标(Critter 图标):以一只程序绘制的“小生物”(Critter)形象呈现当前 Agent 是否在忙碌、正在执行哪一类工作;
  • 菜单第一行状态文本(Status Row):当有会话处于工作状态时,显示<会话角色> · <活动标签>,空闲时则回落到健康(Health)状态摘要;
  • 菜单内的补充区块:Nodes区块只列出设备(经node.list配对的节点),而非客户端/presence 条目;当提供商用量快照可用时,在 Context 下方会额外出现Usage(用量)区块。

值得注意的是健康状态的显示规则:工作状态激活期间健康状态被隐藏,只有全部会话都进入空闲后健康摘要才会回归。这一“非此即彼”的展示策略,避免用户被同时呈现的两类信息干扰,保证菜单栏信息永远是单一、明确的焦点。

从源码结构看,这一逻辑的载体分别位于 MenuBar.swift(菜单栏入口与状态项装配)、MenuContentView.swift(菜单内容视图)与 NodesMenu.swift(Nodes 区块渲染)。

二、会话状态模型:main 会话优先与“不摇摆”切换规则

菜单栏状态机的核心输入是会话(Session)的活动事件。事件到达时带有两个关键标识:

  • runId:标识一次运行(per-run),与事件序列号seq共同组成事件的唯一id(形如"\(runId)-\(seq)");
  • sessionKey:位于事件data负载中,标识事件所属的会话。

ControlAgentEvent在 ControlChannel.swift 中定义为:

struct ControlAgentEvent: Codable, Sendable, Identifiable { var id: String { "\(self.runId)-\(self.seq)" } let runId: String let seq: Int let stream: String let ts: Double let data: [String: ClawdbotProtocol.AnyCodable] let summary: String? }

其中main是“主会话”的约定键名。若某事件未携带sessionKey,则回落到"main"(见 ControlChannel.swift 的routeWorkActivity)。

会话状态裁决遵循两条核心规则:

  1. main 永远优先:如果main会话处于活动状态,立即展示其状态,即使此刻有非 main 会话正在工作;
  2. 不做中途摇摆(no flip-flop):当 main 空闲、非 main 会话活跃时,展示最近活跃的非 main 会话;状态只在“当前会话变为空闲”或“main 变为活跃”这两个时机切换,绝不会在活动进行中反复横跳。

这一策略在 WorkActivityStore.swift 中有完整的代码印证:setJobActive/setToolActive中,当新活动的role == .main时立即抢占currentSessionKey;否则仅在currentSessionKey == nil或当前会话已不活跃时才切换。pickNextSession()则优先检查 main 是否活跃,其次按lastUpdate时间戳选出最近更新的会话(WorkActivityStore.swift)。

活动类型(ActivityKind)与角色(SessionRole)

底层用两个枚举为状态建模(IconState.swift):

enum SessionRole { case main case other } enum ToolKind: String, Codable { case bash, read, write, edit, attach, other } enum ActivityKind: Codable, Equatable { case job case tool(ToolKind) }

两类活动事件的含义:

  • job:高层命令执行(command execution),携带state: started | streaming | done | error,代表一次完整任务的开始与结束;
  • tool:工具调用过程,携带phase: start | result,以及toolName(即name)和可选的meta/args信息。

在 WorkActivityStore.swift 的handleJob中,started与streaming被视作任务开始,其余状态(done/error/aborted等)则清空该会话的 job 与 tool 记录。

三、IconState 枚举:四种状态的完整建模

IconState是菜单栏图标状态的顶层抽象(IconState.swift),共四种取值:

状态含义
idle空闲,无会话工作
workingMain(ActivityKind)main 会话正在工作,携带具体活动类型
workingOther(ActivityKind)非 main 会话正在工作
overridden(ActivityKind)调试覆盖态,忽略真实活动、强制展示指定图标

每个状态还推导出两个派生属性:

  • badgeSymbolName:将活动类型映射为 SF Symbol 名称;
  • badgeProminence:徽标显著度(primary/secondary/overridden),用于控制徽标在图标上的视觉强度。
enum IconState: Equatable { case idle case workingMain(ActivityKind) case workingOther(ActivityKind) case overridden(ActivityKind) enum BadgeProminence: Equatable { case primary case secondary case overridden } var badgeSymbolName: String { switch self.activity { case .tool(.bash): "chevron.left.slash.chevron.right" case .tool(.read): "doc" case .tool(.write): "pencil" case .tool(.edit): "pencil.tip" case .tool(.attach): "paperclip" case .tool(.other), .job: "gearshape.fill" } } }

isWorking则区分忙碌与空闲——idle返回false,其余一律为true,供上层判断是否需要暂停健康状态展示。

四、ActivityKind → 图标字形:五类活动 + 默认

ActivityKind到图标字形(glyph)的映射遵循如下规则(文档原始定义,源码中以 SF Symbol 名称一一对应):

ActivityKind字形
exec(命令执行)💻
read(读取)📄
write(写入)✍️
edit(编辑)📝
attach(附件)📎
默认(其他 / job)🛠️

在源码实现中,ToolKind的映射是:bash/shell→.bash,read→.read,write→.write,edit→.edit,attach→.attach,其余 →.other(见 WorkActivityStore.swift 的mapToolKind)。对应 SF Symbol 为chevron.left.slash.chevron.right(终端)、doc(文档)、pencil(写入)、pencil.tip(编辑)、paperclip(附件)、gearshape.fill(默认)。

五、视觉映射:idle / workingMain / workingOther / overridden 的呈现差异

四种状态在菜单栏图标的视觉呈现上有明确区分(源码见 CritterIconRenderer.swift 的makeIcon与drawBadge):

  • idle:显示普通 Critter,无徽标,正常眨眼的空闲神态;
  • workingMain:显示徽标(badge)+ 完整色调(full tint)+ 腿部“工作”动画(leg working animation),即 Critter 的小腿摆动起来,视觉上“正在奔跑”;
  • workingOther:显示徽标 + 弱化色调(muted tint),且没有腿部奔跑动画(no scurry),以区分忙碌来源的优先级;
  • overridden:无视真实活动,强制使用用户选择的字形与色调。

徽标强度由BadgeProminence控制:primary为 1.0、secondary为 0.58、overridden为 0.85(CritterIconRenderer.swift)。绘制时先以 36×36px(18pt 逻辑画布)保证 Retina 清晰度,再在右上角绘制“puck”式徽标:扩大直径、以 clear 混合模式镂空出 SF Symbol 形状,最后以isTemplate = true输出,适配菜单栏模板渲染。

六、状态行文案(Status Row Text):工作态与空闲态

菜单第一行的状态文本遵循:

  • 工作态:<会话角色> · <活动标签>
    • 示例:Main · exec: pnpm test
    • 示例:Other · read: apps/macos/Sources/Clawdbot/AppState.swift
  • 空闲态:回落到健康状态摘要(health summary)。

活动标签由WorkActivityStore.buildLabel生成(WorkActivityStore.swift),其规则:

  • exec:取args.command的首行;
  • read/write:展示缩短后的文件路径;
  • edit:展示路径 + 根据meta/ diff 计数推断的变更类型;
  • 兜底:直接展示工具名。

标签渲染进一步由ToolDisplayRegistry支撑(ToolDisplay.swift):它读取一份可解码的工具显示配置(含emoji、title、label、detailKeys与按action细分的actions),并根据工具的name、args(如action字段)与meta解析出label + detailLine的组合文案。detailLine由动词(verb)与细节(detail)以·连接而成,最终形成类似Main · bash: pnpm test的完整状态行。

七、事件摄取链路:从控制通道到状态机

7.1 事件源与解析

菜单栏状态的事件源是控制通道(control-channel)上的agent事件。链路如下(ControlChannel.swift):

  1. startEventStream()通过GatewayConnection.shared.subscribe()订阅网关推送流;
  2. 每条 push 在主线程进入handle(push:),若evt.event == "agent"则用GatewayPayloadDecoding.decode解码为ControlAgentEvent;
  3. 解码成功后先写入AgentEventStore(供事件窗口回放),再调用routeWorkActivity(from:)路由到工作状态机;
  4. routeWorkActivity提取sessionKey(缺失时默认"main"),再按stream分发:
    • stream == "job":读取data.state并调用WorkActivityStore.shared.handleJob(sessionKey:state:);
    • stream == "tool":读取data.phase、data.name、data.meta,并将data.args通过bridgeToProtocolArgs桥接为协议参数后调用handleTool(...)。

解析字段汇总:

字段来源用途
stream: "job"事件流名任务级状态,配data.state(started/streaming 开始,其余结束)
stream: "tool"事件流名工具级状态,配data.phase(start/result)、name、可选meta/args
sessionKeydata.sessionKey会话归属,默认"main"

7.2 状态机与防抖动

WorkActivityStore内部以两个字典维护活跃活动:

private var jobs: [String: Activity] = [:] private var tools: [String: Activity] = [:]
  • jobs:按会话键保存进行中的 job;
  • tools:按会话键保存进行中的 tool 调用;
  • toolSeqBySession:记录每个会话的工具序号,用于结果事件去抖。

关键防抖动设计:工具result事件到来时,并不立即移除工具活动,而是延迟2.0 秒(toolResultGrace = 2.0)后、且确认期间没有新的start序列号产生,才清除该工具活动(WorkActivityStore.swift)。这正是“快速工具突发时徽标不闪烁”的底层保障。

refreshDerivedState()每次增删活动后都会重算currentSessionKey、current与iconState,其中deriveIconState()依据当前会话角色产出workingMain/workingOther/idle(WorkActivityStore.swift)。MenuBar的effectiveIconState再决定是直接采用状态机结果,还是应用调试覆盖。

八、调试覆盖(Debug Override):强制指定图标状态

当需要调试或演示特定工作态时,可通过Settings ▸ Debug ▸ Experiments ▸ “Icon override”选择器强制指定图标状态(DebugSettings.swift),选项包括:

  • System (auto)(默认):完全由状态机自动推导;
  • Working: main:按工具类型强制展示 main 工作态(bash / read / write / edit / other 五种);
  • Working: other:按工具类型强制展示非 main 工作态;
  • Idle:强制空闲。

底层实现:

  1. 选择器以@AppStorage(iconOverrideKey)持久化原始字符串(UserDefaults键为"clawdbot.iconOverride",见 Constants.swift);
  2. AppState.iconOverride类型为IconOverrideSelection,didSet时写入UserDefaults(AppState.swift);
  3. IconOverrideSelection是String类型的CaseIterable枚举(IconState.swift),共 11 个 case:system、idle、mainBash/mainRead/mainWrite/mainEdit/mainOther、otherBash/otherRead/otherWrite/otherEdit/otherOther,通过toIconState()映射为IconState;
  4. 选择变更时,DebugSettings的bindingOverride会同步调用AppStateStore.shared.iconOverride = selection与WorkActivityStore.shared.resolveIconState(override: selection)(DebugSettings.swift);
  5. MenuBar.effectiveIconState最终裁决:选择为.system时采用状态机的activityStore.iconState,否则将覆盖选择映射为.overridden(kind)或.idle(MenuBar.swift)。

注意区分两处覆盖语义:IconOverrideSelection.system会回落到真实状态,而其它选择会进入IconState.overridden,后者无视真实活动、以所选字形与色调渲染。

九、测试清单:验证状态机的五个关键场景

文档提供的测试清单可作为验证与回归的核心依据,结合源码可以确认每个场景对应的代码路径:

  1. 触发 main 会话 job:验证图标立即切换且状态行显示 main 标签。对应handleJob中role == .main立即抢占currentSessionKey的路径。
  2. main 空闲时触发非 main job:图标/状态行展示非 main,且保持稳定直到其完成。对应pickNextSession优先 main、其次按lastUpdate选取最近会话的逻辑。
  3. other 活跃时启动 main:图标瞬间翻转为 main。对应setJobActive/setToolActive中 main 预占(preempts immediately)的分支。
  4. 快速工具突发:确保徽标不闪烁。对应toolResultGrace = 2.0秒延迟清除 + 序列号守卫(guard self.toolSeqBySession[key, default: 0] == seq else { return })。
  5. 全部会话空闲后健康行回归:对应refreshDerivedState在currentSessionKey失活时置空、iconState回落idle后健康摘要重新展示。

十、延伸:Nodes 区块与 Usage 区块的展示边界

菜单中的Nodes区块仅列出设备——即通过node.list配对的节点(Node),而非客户端或 presence 条目。相关渲染集中在 NodesMenu.swift,它基于NodeInfo结构体渲染节点名称、平台、版本与角色文本(如primaryName、roleText、versionLabels等辅助方法),并区分 gateway 节点与普通设备节点。

Usage区块则在提供商用量快照可用时出现于 Context 下方,用于展示模型提供商的使用量/成本信息。其数据来源于UsageData/UsageCostData模型与 UsageMenuLabelView.swift 的渲染组件,属于菜单栏的补充信息面,与工作状态展示互不干扰。

十一、相关实现文件索引

职责文件
菜单栏入口、状态项装配、图标状态裁决MenuBar.swift
状态枚举与覆盖选择IconState.swift
工作状态机(会话裁决、防抖、标签构建)WorkActivityStore.swift
控制通道事件摄取与路由ControlChannel.swift
Critter 图标绘制与徽标渲染CritterIconRenderer.swift
调试覆盖 UI 与持久化DebugSettings.swift
工具标签显示解析ToolDisplay.swift
Nodes 区块渲染NodesMenu.swift
用量区块渲染UsageMenuLabelView.swift

本文档最初定位为菜单栏状态逻辑的开发参考(read_when: Tweaking mac menu UI or status logic),其原始内容见 docs/platforms/mac/menu-bar.md。如需深入理解底层事件契约,可继续阅读 GatewayModels.swift 中的AgentEvent定义(含runId、seq、stream、ts、data字段)。

  • 人工智能
  • AI Agent
  • 即时通讯
  • 后端
  • 本地部署
  • 语音

【免费下载链接】openclaw-cn

中文社区版OpenClaw,同原版保持定期更新,已内置钉钉、企业微信、飞书、QQ、微信以及国内网络环境优化。你的专属个人AI助手。支持所有操作系统和平台。🦞

项目地址:https://gitcode.com/gh_mirrors/op/openclaw-cn
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进