跳到主要内容

软件光标:后台操作也"看得见"的视觉叠层

30 秒导读: 这个项目做动作时不移动真实鼠标——点击/输入是通过无障碍(AX)API 或往目标进程 postToPid 直接注入的。问题是:用户就看不见 agent 在干嘛了。解决办法很巧:另画一支假光标,一个独立的浮层小窗,让它沿贝塞尔曲线滑到目标、按一下、停住。真实操作照旧后台进行,这支假光标纯粹"给人看"。本章讲这支软件光标从开关、坐标、调用时机到运动/渲染/清场的完整一条链。

这一章讲的是一个别的 CUA(computer-use agent)通常没有的特性。先说清它为什么存在,再一层层往下讲它怎么实现。

如果你还没读过动作是怎么落地的,建议先看 03-action-execution.md——那一章讲"点击怎么真的点到";本章讲的是"点击的同时,怎么让人看见"。

1. 这是什么(零基础也能懂)

先看普通 CUA 怎么做

大多数 computer-use agent 走的是"抢鼠标"的路子:模型说"点 (800, 400)",程序就真的把系统鼠标挪过去、按一下。好处是所见即所得;坏处是用户的鼠标被夺走了,你想同时干别的事都不行,而且鼠标乱窜看着吓人。

这个项目反过来做

Open Codex Computer Use 的动作不碰真实鼠标。它优先用无障碍 API 直接给控件发 AXPress,或往目标进程投递合成事件(详见 03)。这样:

  • 用户的真实鼠标完全自由——agent 在后台点,你的光标不动;
  • 甚至目标 app 不在前台都能被操作。

代价是:用户失去了视觉反馈。屏幕上没有任何东西告诉你"agent 现在在点这里"。

于是:画一支假光标

解决办法就是本章的主角——软件光标(software cursor):一支渲染出来的、独立于系统硬件光标的小箭头浮层。它:

  • 沿一条平滑的贝塞尔曲线滑向目标;
  • 到了目标按一下(pulse,带按压动画);
  • 然后停住、轻微摇摆(idle),等下一个动作。

真实的点击事件照旧走后台注入;这支假光标只负责给人看。用一句话建立直觉:

真实输入注入 与 光标可视化,是彻底分开的两件事。 一个负责"真的点到",一个负责"让人看见点在哪"。视觉上对齐,逻辑上分离。

它从哪来:逆向 OpenAI 官方实现

这不是拍脑袋设计的。作者逆向了 OpenAI 官方的 Codex Computer Use.app(bundled computer-use service),发现官方也有一支叫 Software Cursor 的独立浮层窗口,会随每次工具动作移动,而且不在 get_app_state/click 返回的截图里——说明它是给人看的 overlay,不是被控 app 内部的绘制层。相关观察记录在:

  • docs/references/codex-computer-use-reverse-engineering/software-cursor-overlay.md(这支光标是独立软件光标而非硬件光标的证据链)
  • software-cursor-motion-reconstruction.md / software-cursor-motion-model.md(贝塞尔运动模型的复原)

本开源实现就是照着这套逆向结论重建的

2. 顶层全景(它大概怎么转)

一次点击时,这支光标经历什么

ComputerUseService.click(...)

┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
① 算出屏幕态坐标 ② 真实动作注入 ③ 光标可视化
(无障碍树里控件的 (走 AX / postToPid, (画假光标,给人看)
localFrame → 全局点) 不动真鼠标)
│ │
│ screenStatePointToAppKitGlobalPoint │
│ y-down 屏幕态 ──► y-up AppKit 全局 │
▼ ▼
VisualCursorTarget ─────────────────────────► SoftwareCursorOverlay
{ point, window } │
┌────────────────────┼────────────────────┐
▼ ▼ ▼
moveCursor(动作前) pulseClick(动作时) settle(动作后)
贝塞尔滑向目标 按压脉冲动画 停住+idle摇摆
│ │ │
└────────────────────┴────────────────────┘

CursorMotionModel(算路径 + 弹簧运动学)
SoftwareCursorGlyphRenderer(画那支箭头 + fog)

NSPanel 浮层窗口(贴在目标窗口之上)

── 回合结束 ──► turn-ended 通知 ──► reset() 清场

部件一句话职责

部件干什么在哪个文件
ComputerUseService 里的 moveVisualCursor / pulseVisualCursor / settleVisualCursor在真实动作前/时/后调用光标ComputerUseService.swift
screenStatePointToAppKitGlobalPoint坐标系转换:y-down 屏幕态 → y-up AppKit 全局ComputerUseService.swift:29
SoftwareCursorOverlay光标状态机 + 浮层窗口管理 + 贴附目标窗口SoftwareCursorOverlay.swift:186
HeadingDrivenCursorMotionModel / CursorMotionPath生成贝塞尔运动路径候选,挑最像人手的一条CursorMotionModel.swift
CursorVisualDynamicsAnimator弹簧运动学:让光标平滑追目标、按速度偏转CursorMotionModel.swift:1242
SoftwareCursorGlyphRenderer真正把那支灰白箭头 + fog 画到窗口上SoftwareCursorGlyphRenderer.swift:58
VisualCursorSupport.isEnabled / visualCursorEnabled总开关(环境变量)SoftwareCursorOverlay.swift:6
resetOpenComputerUseVisualCursor / turn-ended 通知回合结束清场SoftwareCursorOverlay.swift:135

主线走一遍(高层,不进代码)

  1. click 拿到目标控件,算出它在屏幕上的全局像素点;
  2. 把这个点转成一个 VisualCursorTarget(含目标点 + 目标窗口);
  3. 动作前moveVisualCursor——假光标沿贝塞尔曲线滑过去;
  4. 真实点击照旧后台注入(AX 或事件);
  5. 动作后pulseVisualCursor——假光标在目标处按一下、停住;
  6. 一整个回合结束,host 收到 turn-ended,reset() 把光标清掉。

3. 核心原理(逐个机制,由浅入深)

3.1 总开关:什么时候画,什么时候不画

它要解决的小问题: 这支光标是可选体验,得能一键关掉(比如跑测试、或用户不想要)。

开关逻辑: 默认,只有环境变量显式设成假值才关。看 visualCursorEnabled:

// SoftwareCursorOverlay.swift:27 —— 真实源码
func visualCursorEnabled(environment: [String: String]) -> Bool {
guard let rawValue = environment["OPEN_COMPUTER_USE_VISUAL_CURSOR"]?
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else {
return true // 没设 → 默认开
}
return !["0", "false", "no", "off"].contains(rawValue)
}

上层通过 VisualCursorSupport.isEnabled(SoftwareCursorOverlay.swift:7)读这个开关。每个入口(moveCursor/pulseClick/settle)开头都先 guard VisualCursorSupport.isEnabled, canPresentOverlay(SoftwareCursorOverlay.swift:202),关了就直接返回、什么都不画。canPresentOverlay 还额外要求"至少有一块屏"(SoftwareCursorOverlay.swift:285)。

3.2 坐标系转换:两个世界的对齐

它要解决的小问题: 无障碍树给的坐标和 AppKit 摆窗口用的坐标,y 轴方向相反。不转换,光标会画到上下颠倒的位置。

两个坐标系:

坐标系谁在用y 轴方向原点
屏幕态(screen-state)无障碍树、CGDisplayBounds、模型给的坐标y 向下屏幕左上
AppKit 全局NSPanel 摆窗口用的y 向上主屏左下

转换靠一张映射表 + 一次翻转。 先给每块屏建立"屏幕态矩形 ↔ AppKit 矩形"的对应:

// ComputerUseService.swift:11 / :16 —— 真实源码
struct VisualCursorScreenMapping: Equatable {
let screenStateFrame: CGRect // CGDisplayBounds(...) y-down
let appKitFrame: CGRect // screen.frame y-up
}

真正的转换在 screenStatePointToAppKitGlobalPoint——x 直接平移,y 用 maxY - localY 翻过来:

// ComputerUseService.swift:29 —— 真实源码(截关键三行)
let localX = point.x - mapping.screenStateFrame.minX
let localY = point.y - mapping.screenStateFrame.minY
return CGPoint(
x: mapping.appKitFrame.minX + localX,
y: mapping.appKitFrame.maxY - localY // ← y-down 翻成 y-up
)

这个函数被 makeVisualCursorTarget(ComputerUseService.swift:63)调用,把控件的屏幕态点变成光标窗口能用的 AppKit 全局点,装进 VisualCursorTarget

还有一层暗坑:窗口摆放用 AppKit,但光标内部的运动学状态还按 y-down 算。 所以渲染前又要把速度的 y 分量翻一次:

// SoftwareCursorOverlay.swift:58 —— 真实源码 + 注释
func visualCursorRuntimeRenderYAxisMultiplier() -> CGFloat {
// Window placement uses AppKit global coordinates, but glyph render state is
// still interpreted as CursorMotion's y-down screen state before drawing.
-1
}

这个 -1CursorVisualDynamicsAnimator.advance 里用 visualCursorScreenStateVelocity(SoftwareCursorOverlay.swift:64)把速度翻过来,保证箭头的偏转方向和实际运动方向一致。记住这条:一次点击涉及三次坐标口径的对齐——屏幕态↔AppKit(摆窗口)、y 分量翻转(运动学)、还有渲染时的 y 翻转(SoftwareCursorGlyphRenderState.appKitDrawingState,SoftwareCursorGlyphRenderer.swift:29)。

3.3 三个调用点:动作前 / 动作时 / 动作后

它要解决的小问题: 假光标要和真实动作在时间上对齐,不能真实点击都点完了、假光标才慢悠悠飘过去。

三个语义分明的调用:

方法语义什么时候调
moveVisualCursor滑向目标真实动作之前
pulseVisualCursor按压脉冲(点几下画几下)点击类动作之后
settleVisualCursor停住、进入 idle非点击动作(如 set_value)之后,或出错时

它们是 ComputerUseService 的私有方法,都用 VisualCursorSupport.performOnMain 保证在主线程画:

// ComputerUseService.swift:1531 —— 真实源码
private func moveVisualCursor(to target: VisualCursorTarget?) {
guard let target else { return }
VisualCursorSupport.performOnMain {
SoftwareCursorOverlay.moveCursor(to: target.point, in: target.window)
}
}

click 里的实际时序(ComputerUseService.swift:405起):先 moveVisualCursor 让光标滑过去 → 再执行真实 AX 点击级联 → 成功后 pulseVisualCursor 按一下;如果中途 throw 了,catch 里改调 settleVisualCursor(ComputerUseService.swift:425),让光标停在目标处而不是卡在半路。这一点呼应逆向结论——即使执行失败,overlay 也保持可观测,利于调试和用户信任

set_value 走的是 move → 真实 AXUIElementSetAttributeValuesettle(ComputerUseService.swift:630),因为赋值不是"点击",不需要按压脉冲。

3.4 贝塞尔运动:让假光标"像人手"

它要解决的小问题: 假光标要是直线瞬移过去,一眼假。要像人手,得走弧线、有加减速。

思路:生成一堆候选路径,评分挑最像人手的那条。 HeadingDrivenCursorMotionModel.makeCandidates(CursorMotionModel.swift:597)按当前朝向和目标,生成若干条不同风格的贝塞尔路径——direct(直冲)、turn(掉头)、brake(减速逼近)、orbit(绕行)。每条由一个 MotionDescriptor 描述控制点权重(CursorMotionModel.swift:820起,一长串手调参数)。

路径本身是三次贝塞尔曲线段的序列。 CursorMotionPath 存起点、终点和若干 CursorMotionSegment(每段带两个控制点 control1/control2),sample(at:) 在曲线上取点和切线:

// CursorMotionModel.swift:4 / :10 —— 真实源码(结构)
struct CursorMotionSegment: Equatable {
let end: CGPoint
let control1: CGPoint // 三次贝塞尔的两个控制点
let control2: CGPoint
}
struct CursorMotionPath: Equatable {
let start: CGPoint
let end: CGPoint
let segments: [CursorMotionSegment]
// ...
}

怎么评分? scoreCandidate(CursorMotionModel.swift:764)综合"多余长度、角度变化能量、最大转角、总转角、起止朝向误差、是否越界"打分,chooseBestCandidate(CursorMotionModel.swift:642)在留在屏内的候选里挑分最低(最平顺)的一条。

运动时序不是匀速播放,而是弹簧。 CursorMotionProgressAnimator(CursorMotionModel.swift:243)用一套 .official 弹簧参数(response: 1.4, dampingFraction: 0.9,CursorMotionModel.swift:215)推进进度 0→1,这套参数是从逆向官方"progress spring timing"复原来的(见 animateMove 的注释,SoftwareCursorOverlay.swift:352)。

另一层弹簧管姿态。 光标的位置和角度再各自跟一个弹簧(CursorVisualDynamicsAnimator,CursorMotionModel.swift:1242),让箭头平滑追上目标点、并按运动速度偏转朝向、带一点 body/fog 拖影(CursorVisualRenderState,CursorMotionModel.swift:1233)。

3.5 贴附目标窗口:光标要压在被控窗口之上

它要解决的小问题: 假光标浮层如果层级不对,会被目标窗口盖住(看不见),或者飘到别的 app 上面(位置对不上语义)。

思路:相对具体的目标窗口维持排序,而不是钉死一个固定层级。 configureOrdering(SoftwareCursorOverlay.swift:319)拿到 targetWindow 后:

  • 先确认这个 window 还在(isWindowPresent,SoftwareCursorOverlay.swift:524);
  • 把浮层 order(.above, relativeTo:) 排到目标窗口正上方(SoftwareCursorOverlay.swift:340);
  • 目标窗口若失效,回退到普通 orderFront

运动过程中还会持续复查这个排序(refreshActiveOrderingIfNeeded,SoftwareCursorOverlay.swift:534),因为动画期间目标窗口可能被关掉或改层。这直接复现了逆向里 helper 0x10005d7bc 的行为(见 overlay 参考文档"更细一层的实现推断")。

运动路径也做窗口命中检查。 bestMotionCandidate(SoftwareCursorOverlay.swift:400)在有 targetWindow 时,对每条候选路径采样点做 hit-test,数一数有多少采样点仍落在目标窗口上(windowConstraintHitCount,SoftwareCursorOverlay.swift:473),优先选轨迹不飘出被控窗口的那条。这也是照逆向结论(helper 0x10005c388 的 target-window-aware 约束)重建的。

3.6 那支箭头怎么画出来的

它要解决的小问题: 官方的灰白箭头在 bundle 里找不到一张一一对应的静态图(逆向发现它更像运行时代码/图层组合出来的),所以本项目得自己把它渲染出来。

两条渲染路径:

SoftwareCursorGlyphRenderer.draw

有参考图?
┌────┴─────┐
▼ ▼
有:贴图 没有:纯代码画
official- drawFog(径向渐变雾)
software- + drawPointer(贝塞尔勾出箭头轮廓)
cursor-
window-252.png

优先加载逆向时从官方运行时窗口抠出的 official-software-cursor-window-252.png 参考图(loadReferenceCursorWindowImage,SoftwareCursorGlyphRenderer.swift:273);找不到就走纯代码 fallback——drawFog(SoftwareCursorGlyphRenderer.swift:130,径向渐变的雾)加 drawPointer(SoftwareCursorGlyphRenderer.swift:186,用一张 30 行的轮廓表 pointerPath 勾出箭头形状,SoftwareCursorGlyphRenderer.swift:239)。

几何常量都锁死了,来自逆向测量:窗口 126x126、笔尖锚点 (60.35, 70.3)(SoftwareCursorGlyphMetrics,SoftwareCursorGlyphRenderer.swift:41)。笔尖锚点很关键——placeCursor 靠它把"笔尖该在的全局点"换算成"窗口左下角该摆哪"(origin(forTipPosition:),SoftwareCursorOverlay.swift:160)。

按压动画不是换图,是变形:clickProgress 驱动一个轻微的横向压扁 + 纵向拉伸(SoftwareCursorGlyphRenderer.swift:208),配合 animateClickPulsesin(rawProgress * .pi) 的脉冲曲线(SoftwareCursorOverlay.swift:557),看起来就是"按了一下"。

3.7 回合结束:清场

它要解决的小问题: 一个回合(turn)做完,这支假光标不能一直赖在屏幕上,得消失。

机制:一条 turn-ended 生命周期通知 + reset。 逆向发现官方不是每次点击后就销毁光标——位置状态(currentInterpolatedOrigin)和可见性状态是分开的,同一回合内的连续动作从当前光标位置继续移动;只有回合结束才清场(见 overlay 参考文档第 8 节)。本项目照此实现:

  • MCP server 收到 notifications/turn-endedSoftwareCursorOverlay.reset()(MCPServer.swift:69);
  • 或经 CLI turn-ended 命令 → postOpenComputerUseTurnEndedNotification()(SoftwareCursorOverlay.swift:137)广播分布式通知;
  • app 侧监听这个通知 → resetOpenComputerUseVisualCursor()(MCPAppRuntime.swift:29,MacOSAppAgentProxy.swift:174)。

reset() 清空所有位置/窗口/运动状态并把浮层 orderOut(SoftwareCursorOverlay.swift:273)。注意跟 idle 超时区分开: 单次动作后是 settle + 30 秒 idle 超时淡出(visualCursorPostInteractionIdleTimeout,SoftwareCursorOverlay.swift:71;scheduleHide,SoftwareCursorOverlay.swift:638);而 turn-ended 是回合级的立即清场。

4. 巧妙之处(可借鉴的技术)

  • 输入与可视化彻底解耦。 真实点击走 AX/事件注入,假光标只管画。这让"不抢用户鼠标 + 平滑路径 + 点击高亮 + 延迟控制"全都能自由做,而不受真实鼠标位置牵制。见 03 与本章 §1。
  • 失败也保持可观测。 出错时不是让光标卡在半路,而是 settle 到目标,利于调试和用户信任(ComputerUseService.swift:425)。
  • 贝塞尔候选 + 窗口命中约束。 不是"先算好硬播",而是生成多条候选、按平顺度评分、再用目标窗口 hit-test 优选轨迹(SoftwareCursorOverlay.swift:400CursorMotionModel.swift:642)。
  • 双层弹簧。 进度一层弹簧(整体时序)、位置/角度各一层弹簧(姿态追随),叠出"像人手"的质感(CursorMotionModel.swift:243:1242)。
  • 相对目标窗口维持排序 + 动画中持续复查。 而不是钉死一个固定 window level(SoftwareCursorOverlay.swift:534)。
  • 一切照逆向证据重建。 参数、几何、生命周期都锚在对官方 service 的静态/运行时观察上,不是凭空调参(docs/references/codex-computer-use-reverse-engineering/software-cursor-*.md)。

5. 边界与局限

  • 纯 macOS(AppKit NSPanel)。 这套 overlay 是 AppKit 实现,Windows/Linux 端(apps/OpenComputerUseWindowsapps/OpenComputerUseLinux)只处理 turn-ended 通知(main.go:992 / main.go:1409),没有这支软件光标。跨平台一致性见 05-cross-platform-and-packaging.md
  • 纯装饰,不影响正确性。 光标画错/画不出都不该影响动作落地——观测快照写文件失败也被静默吞掉(SoftwareCursorOverlay.swift:770 的注释:"Observation is debug-only and must not affect tool execution")。
  • 不是所有动作都出光标。 type_text / press_key 是纯键盘注入,不调 move/pulse/settle(逆向也观察到官方这两个 tool 不触发 overlay,见 overlay 文档第 7 节)。
  • 箭头不是 100% 复刻。 参考图能抠出来,但纯代码 fallback 仍是近似;逆向文档明确没宣称 procedural 版本 1:1 复刻(overlay 文档"当前推断"第 3-4 节)。

6. 横向对比(同组其它章)

关系
index.md全景 · 阅读地图
01-state-snapshot.md光标目标坐标源自那里的 UI 快照(控件 localFrame)
02-mcp-tools-surface.mdturn-ended 是那里定义的一条 MCP/CLI 通知
03-action-execution.md本章的前提:动作走 AX/注入而非动真鼠标,才有了"画假光标"的需求
05-cross-platform-and-packaging.md这支光标是 macOS 独有,跨平台差异在那里

7. 代码地图(导航索引)

主题文件路径符号名
总开关(环境变量)packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swiftVisualCursorSupport.isEnabled · visualCursorEnabled
坐标转换 y-down→y-uppackages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swiftscreenStatePointToAppKitGlobalPoint · VisualCursorScreenMapping · makeVisualCursorTarget
渲染态 y 翻转packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swiftvisualCursorRuntimeRenderYAxisMultiplier · visualCursorScreenStateVelocity
动作前/时/后三调用packages/OpenComputerUseKit/Sources/OpenComputerUseKit/ComputerUseService.swiftmoveVisualCursor · pulseVisualCursor · settleVisualCursor
overlay 状态机packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swiftSoftwareCursorOverlay · moveCursor · pulseClick · settle · reset
贴附目标窗口packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swiftconfigureOrdering · refreshActiveOrderingIfNeeded · bestMotionCandidate · windowConstraintHitCount
贝塞尔路径与候选packages/OpenComputerUseKit/Sources/OpenComputerUseKit/CursorMotionModel.swiftCursorMotionPath · CursorMotionSegment · HeadingDrivenCursorMotionModel · chooseBestCandidate
运动学弹簧packages/OpenComputerUseKit/Sources/OpenComputerUseKit/CursorMotionModel.swiftCursorMotionProgressAnimator · CursorVisualDynamicsAnimator · CursorMotionSpringConfiguration.official
箭头字形渲染packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorGlyphRenderer.swiftSoftwareCursorGlyphRenderer.draw · drawFog · drawPointer · SoftwareCursorGlyphMetrics
回合结束清场packages/OpenComputerUseKit/Sources/OpenComputerUseKit/SoftwareCursorOverlay.swift · MCPServer.swift · apps/OpenComputerUse/Sources/OpenComputerUse/MCPAppRuntime.swiftpostOpenComputerUseTurnEndedNotification · resetOpenComputerUseVisualCursor · openComputerUseTurnEndedNotificationName
逆向依据docs/references/codex-computer-use-reverse-engineering/software-cursor-overlay.md · software-cursor-motion-reconstruction.md · software-cursor-motion-model.md