Conversation
官方 computer-use 暴露 10 个 tool,本仓库此前只实现 9 个;本次补上唯一缺口 select_text,description / 参数名 / 参数说明 / enum 与官方逐字对齐。 - 新增纯函数 TextSelectionResolver:按 UTF-16 偏移解析目标文本,支持 prefix / suffix 消歧;命中不唯一时 fail closed,不静默选第一个匹配。 - ComputerUseService.selectText 通过 kAXSelectedTextRangeAttribute 落地选区,写后 读回真实 range;目标 app 未接受时在 result 里附 verification note,不伪装成功。 - fixture 支持 select_text 并导出当前选区,fixture 合成 snapshot 透出 Selected text。 - smoke suite 增加第 8 步 select_text 断言;单元测试新增 11 个 resolver 用例、官方 surface schema 断言与非法 selection 用例,工具数量断言 9 → 10。 - 同步 ARCHITECTURE / QUALITY_SCORE / SKILL.md / references/usage.md 的工具面描述, 并按仓库约定记录 docs/histories/2026-09/20260911-2001-add-select-text-tool.md。 验证:swift build 通过;swift test 179 tests / 0 failures(1 skipped); make smoke 通过,输出包含新增的 8. select_text。 平台差异:Windows(Go + PowerShell UIA)与 Linux(Go + AT-SPI2)runtime 仍是 9 个 tool,select_text 未实现,已在 ARCHITECTURE / QUALITY_SCORE 明确标注。
对照 Codex Computer Use 的 P1-P4 补丁计划:默认关闭快照 activate 恢复、 元素自动滚入视口、目标元素高亮环、click.auto 的 sky_click 灰度路径。 不含任何运行产物改动。
对照 Codex Computer Use 收敛 OCU 的焦点行为。四项改动属于同一条 "后台可操作、不抢前台焦点" 链路,因此放在同一个 commit: - P2:SnapshotBuilder.build / refreshSnapshot / clickActionSnapshotRecoveryPolicy 的默认 recoveryPolicy 从 .allowActivation 改为 .readOnly,unhide / activate / open -b / AXRaise / AXMain / AXFocused 收进显式 opt-in:9 个工具的 schema 与 dispatcher 新增可选布尔 allow_window_recovery,进程级用 OPEN_COMPUTER_USE_ALLOW_WINDOW_RECOVERY=1;显式 false 优先于 env。readOnly 下 仍返回 Apple event error -10005: cgWindowNotFound,并附 "移到当前 Space / 取消最小化" 与 opt-in 方式提示。 - P1:新增 ensureElementVisible。AXScrollToVisible(元素自身 -> 最多 6 层祖先) -> 最近 AXScrollArea 的 AXScroll<Direction>ByPage(最多 8 页,每轮读回 frame, 无进展即停);越界判定只看元素 frame 中心点是否落在窗口可视矩形内, localFrame / windowBounds 缺失或退化时不滚动。滚动后按同一 element_index 重新 解析 AX 句柄并读回 frame,读不回或仍越界时 fail closed。接入 click(element_index) / set_value / select_text。默认开启, OPEN_COMPUTER_USE_SCROLL_TARGET_INTO_VIEW=0/false/no/off 关闭。 - P4:新增 TargetHighlightOverlay,复用无边框 non-activating NSPanel 与 screen-state -> AppKit 全局坐标换算;动作前显示、450ms 后 350ms 淡出; canBecomeKey/Main=false、ignoresMouseEvents=true;localFrame 为空或与窗口 可视矩形不相交时不显示;turn-ended / reset 联动隐藏。接入 click / set_value / select_text / perform_secondary_action / scroll。 - P3:新增 autoSkyClickEnabled / automaticSkyClickEligible。默认行为不变; OPEN_COMPUTER_USE_AUTO_SKY_CLICK=1 时 auto 在 postToPid 兜底前先试一次 sky_click,失败落回 postToPid,绝不动态升级到 global 物理指针。 同步 ARCHITECTURE.md、仓库 skill 与 history;未重建或覆盖任何 .app 运行产物。 验证:swift build(Build complete);swift test(189 tests, 1 skipped live, 0 failures);./scripts/check-docs.sh(通过)。 未执行:真实 GUI 端到端验证(验收轮占用桌面,本轮禁止 GUI 自动化)。
元素级动作此前先调 showTargetHighlight、再 moveVisualCursor,用户看到的是 "先亮环、光标才飞过去"。官方 Codex Computer Use 的同线程日志顺序相反: Move cursor to ... / Start Bezier cursor animation ... / Signal cursor movement completion ... 先于 Moving mouse to ... / Clicking at ...,即软件光标先到位、再发生真实交互 (docs/references/codex-computer-use-reverse-engineering/ software-cursor-overlay.md:192-196,243)。 现在两个 advisory overlay 收进 VisualInteractionChoreographer,元素级动作 统一走 move -> settle(120ms) -> highlight -> action: - click / set_value / select_text 交换高亮与移动的先后; - perform_secondary_action / scroll 按官方 tool 矩阵证据补上移动阶段 (software-cursor-overlay.md:193,195); - OPEN_COMPUTER_USE_VISUAL_CURSOR 关闭时两步一起跳过; - canBecomeKey/Main=false、ignoresMouseEvents=true、localFrame 空或不相交 不显示、turn-ended/reset 联动隐藏均保持不变。 验证:swift build(Build complete);swift test(193 tests / 1 skipped / 0 failures,新增 4 个顺序与开关单测);./scripts/check-docs.sh 通过。
- 光标移动只在目标变化时发生:与上次落点相差 ≤2pt 的目标只重画高亮环, 不再重复 move + settle + 到达节拍(VisualCursorMoveCoalescer,阈值 2pt)。 - 目标变化但距上次动画不足合并窗口时不重播移动动画:直接落位到新目标 (SoftwareCursorOverlay.repositionCursor,不播 Bezier、不播 pulse), 默认窗口 400ms,OPEN_COMPUTER_USE_VISUAL_CURSOR_COALESCE_MS 可调, 0 恢复逐次动画;坐标点击与 fixture 路径共用同一个 coalescer。 - 合并窗口锚定“上一次真正播动画”的时刻,同目标不重新锚定,避免亚像素 漂移把光标慢慢带走;cursor 隐藏 / turn-ended / reset 时清空记忆。 - 高亮环不再依赖主 RunLoop 的 Timer:新增 TargetHighlightLifetime,用后台 DispatchSourceTimer 计硬 TTL(常规 450ms 可见 + 350ms 淡出,菜单/弹层 AXMenuItem 等 300ms),并在 TTL + fade + 50ms 处无条件 orderOut;主线程 忙或 run loop 处于原生菜单 tracking 模式时也不会留下常驻环。 - 环可见期间每 220ms 做一次存活看门狗:AX 元素失效、frame 位移/尺寸变化 超过 8pt、目标窗口消失(原生下拉菜单关闭)立即隐藏;新 approach 先清 旧环,VISUAL_CURSOR=0 时连旧环一起隐藏。 canBecomeKey/Main=false、ignoresMouseEvents=true、localFrame 空或与窗口 不相交不显示、VISUAL_CURSOR=0/false/no/off 全跳过、turn-ended/reset 联动 隐藏等既有约束不变。同步 ARCHITECTURE.md、exec-plan、history 与仓库 skill。 验证:swift build(Build complete);swift test(207 tests / 1 skipped / 0 failures,新增 14 个去抖与高亮生命周期单测);./scripts/check-docs.sh 通过。 未执行:GUI 端到端验证(验收轮占用桌面)。
用户实测光标小箭头在原位小幅抖/跳,且 AI 空闲不动手时也在抖。空闲抖动说明 是周期性更新在驱动 overlay,根因三处: - idle 定时器泄漏:startIdleAnimation 直接覆盖 idleTimer 引用、从不 invalidate 旧定时器,而 settle / pulseClick 只 start 不 stop。每个 animated 动作 (settle → pulseClick)都永久漏掉一个 60Hz writer,多个定时器同时推进共享 静态 idlePhase,原本平滑的微摆变成帧间不等距的阶梯,且泄漏的定时器再也 停不掉(stopIdleAnimation 只认最新一个)。 - 每 tick 强制重排面板:idle tick 第一句就是 refreshActiveOrderingIfNeeded → configureOrdering(forceReorder: true) → panel.order(.above, relativeTo:), 空闲时每 16.7ms 重排一次 NSPanel。 - 亚像素 frame 写入:placeCursor 无条件 setFrameOrigin(弹簧积分器的分数点 tip),idle 期弹簧向 resting 点渐近收敛,每帧写一个亚像素新值,AppKit 量化 到 backing store 时在边界整像素跳变,同时每帧 needsDisplay 全量重绘。 修法(新增 SoftwareCursorIdlePolicy.swift,改 SoftwareCursorOverlay): - CursorIdleDriver 成为 idle 定时器唯一 owner:start 前先 stop;tick 先过 lastInteractionAt + 有界节拍的“空闲守卫”,越界即自 invalidate 并直接 return;move / reposition / settle / pulse 刷新 lastInteractionAt。 - CursorPanelWriteGate 统一 panel 写入:frame origin 取整到整点且数值相等即 整帧跳过;level 相等不写;order(.above, relativeTo:) 只在显示/隐藏或目标 窗口变化时执行,idle tick 不再重排;隐藏 / reset 清空记忆。 - refreshActiveOrderingIfNeeded 只处理目标窗口消失,不再强制重排活着的目标 窗口;render state 未变化时不再写 view / needsDisplay。 - 新增 OPEN_COMPUTER_USE_VISUAL_CURSOR_IDLE_SWAY_MS(默认 1000ms,0 表示动作 落定即冻结)与 OPEN_COMPUTER_USE_VISUAL_CURSOR_DEBUG_STATS=1 计数输出。保留 1 秒有界微摆,因为 runCursorIdleSmoke 仍断言 idle 期 rotation 在变。 已排除:TargetHighlightLifetime 的 220ms 看门狗只碰高亮环自己的 panel; cursor 目标点只在动作时由 visualCursorTarget 从 snapshot 算一次,不存在每 tick 重读元素 frame 的漂移路径。 验证:swift build(Build complete);swift test(214 tests / 1 skipped / 0 failures,基线 207 + 新增 7 个空闲静止单测);./scripts/check-docs.sh 通过。 同步 ARCHITECTURE.md、exec-plan、history 与仓库 skill。
为什么:该技能文档是使用者了解 macOS quiet 默认行为、光标去抖与后台点击开关的唯一入口, 开关说明缺失会导致按默认行为误判工具能力(例如不知道 auto 何时会尝试 sky_click)。 行为变化:macOS Operating Rules 补充 OPEN_COMPUTER_USE_AUTO_SKY_CLICK(默认关;auto 在 accessibility 与 app_post 之间尝试一次 sky_click,失败即回落,绝不升级到 global); 并把 OPEN_COMPUTER_USE_VISUAL_CURSOR_IDLE_SWAY_MS 的默认值由隐式 "1s" 改为显式 1000 ms。 验证:仅文档改动,未执行构建;git diff --cached --check 通过(无 whitespace 错误)。
为什么:文档此前只说明了光标去抖与空闲静止行为,未说明如何整体关闭叠加层, 使用者无法在不改代码的前提下关掉虚拟光标与目标高亮环。 行为变化:在 macOS quiet-mode 段补充 OPEN_COMPUTER_USE_VISUAL_CURSOR(默认开; =0 / false / no / off 同时关闭虚拟光标与目标高亮环)。 验证:仅文档改动,未执行构建;git diff --cached --check 通过(无 whitespace 错误)。
三个根因与修法:
1) 光标整支消失(不是停止摆动)。panel level 取目标窗口 layer(普通窗口 = 0),
而 OCU 是永不 active 的 accessory app,.normal 层级属于非活跃窗口组,任何一次
前台切换都会让活跃 app 的窗口盖到光标之上;当时唯一把 level-0 panel 抬到目标
窗口之上的 order(.above, relativeTo: 外部 windowID) 只在动作开始时执行,系统级
重排后不会重新置前;目标窗口消失时 level 又回落到 0 + orderFront,对非活跃 app
等于不可见;此外 30 秒 idle 隐藏定时器会在模型长思考(>30s 无工具调用)时把整支
光标淡出。修法:新增 cursorOverlayBaseLevel(.floating) 与纯函数
cursorPanelOrdering(普通窗口固定 .floating 且不再相对外部窗口排序,仅当目标
窗口自身 >= .floating 才保留相对排序),删除整套 idle hide(隐藏只剩 turn-ended /
reset / OPEN_COMPUTER_USE_VISUAL_CURSOR=0),新增
didActivateApplicationNotification 触发的重新置前(只重排、不写 frame、不改
level),并把窗口交互面抽成 CursorOverlayPanelHosting /
CursorOverlayEnvironment 注入缝,使「目标窗口消失 / 别的 app 抢到前台后光标仍
visible 且 frame 不变」可无 window server 回归。
2) 仍会抢焦点。element_index 的 .auto 与 .accessibility 两条路径硬编码
allowActivationFallback: true,直接落到 activateClickTarget 的 AXRaise /
AXMain / AXFocused(canUseActivationOnlyClickFallback 恰好允许 AXWindow)。
修法:新增 activationOnlyClickFallbackAllowed,与窗口恢复共用同一个
allow_window_recovery 开关;默认路径只剩 AXPress / AXConfirm / AXOpen /
AXShowMenu、子孙与命中点候选、自动滚入视口、postToPid / sky_click,坐标点击
路径恒定不激活。
3) 目标高亮环「看不见」。核查结论是触发了也画了,只是视觉上等于没有:旧样式
stroke 与 fill 都用 NSColor.controlAccentColor,与浏览器原生 focus ring 同色
同形;重做第一版把光标的白色描边(white 0.90@0.92)当环描边,浅色页面上实测
几乎不可见;环 panel level 也跟随目标窗口 layer (=0),存在与 (1) 相同的被盖 /
被外部窗口带走风险。修法:新增 TargetHighlightStyle(codex 默认 / plain 逐像素
回退,env OPEN_COMPUTER_USE_TARGET_HIGHLIGHT_STYLE),深色描边
0.38/0.36/0.35@0.85(光标主体色)+ 1pt 浅色外缘 white 0.90@0.55(光标边缘色)
+ 33pt 雾状光晕(光标 fog 半径与颜色),环 level 与光标统一取 .floating 下限;
官方 binary 没有「目标高亮」概念,因此只对齐光标视觉语言而不冒充官方行为。
可验证性:新增单屏调试入口 debug-highlight [--seconds N] [--display N](只画本地
overlay、不做 AX 调用、不发事件、不激活任何 app,--seconds 上限 60s,defer 确定性
收尾),打印可直接喂给 screencapture -R 的 screen-state 矩形;
OPEN_COMPUTER_USE_DEBUG_HIGHLIGHT=1 时每次 approach 打一行
[open-computer-use] highlight present/skip,用于区分「没触发」(坐标点击本来就不画
环,无任何行)与「触发了但被几何条件跳过」。
验证:swift build Build complete;swift test 245 tests / 1 skipped / 0 failures
(基线 214,新增 31:CursorOverlayVisibilityTests 7、ClickActivationPolicyTests 4、
TargetHighlightStyleTests 11、DebugHighlightTests 9);./scripts/check-docs.sh 通过;
debug-highlight --seconds 2 --display 1 打印 ring=934,548,180,56 并在 3.1s 内 exit=0,
pgrep -x OpenComputerUse 无残留进程/panel;截图证据
tmp/ocu-highlight-21daf46/{codex-ring,plain-ring,codex-ring-v2}.png。
官方 Codex Computer Use 没有“目标高亮”概念:逆向文档全目录 grep -in highlight 零命中,运行时可视化只有 Software Cursor(126x126 画布、 CursorView/SoftwareCursorStyle/FogCursorStyle、Bezier 移动、click pulse)。 OCU 自建的 TargetHighlight* 属本项目扩展,保留 codex|plain 两种样式属冗余 代码,用户裁定整体物理删除。 - 删除 TargetHighlightOverlay / TargetHighlightLifetime / TargetHighlightStyle - VisualInteractionChoreographer 去掉 showTargetHighlight 注入、 presentTargetHighlight 与 approach 的 record/snapshot 形参,顺序收敛为 move -> settle -> 调用方动作;ComputerUseService.approachVisualTarget 只收 VisualCursorTarget - SoftwareCursorOverlay.reset() 去掉环清理;删除 OPEN_COMPUTER_USE_TARGET_HIGHLIGHT_STYLE / OPEN_COMPUTER_USE_DEBUG_HIGHLIGHT 与 highlight 诊断行 - debug-highlight -> debug-cursor:只画光标与脉冲,仍单屏 / --seconds 上限 60s / defer 确定性收尾 / 打印 screencapture -R 矩形 - 测试:删 TargetHighlightStyleTests(11) 与 DebugHighlightTests(9),改写为 DebugCursorTests(7);编排断言只保留 move/settle/reposition - 文档:ARCHITECTURE、exec-plan、新增 history;SKILL.md 注明只有软件光标与 点击脉冲(与官方一致) - 光标行为与默认参数不变(雾状光晕 / Bezier 到位 / click pulse / 400ms 去抖 / 常驻可见 / 单屏 / .floating 层级) 验证:swift build Build complete;swift test 222 tests / 1 skipped / 0 failures (删除前 245);./scripts/check-docs.sh 通过;debug-cursor --seconds 2 --display 1 截图 tmp/ocu-cursor-only-c434426/ 证明只有光标、无环。
根因:currentSnapshot 直接复用 snapshotsByApp 缓存,windowBounds / targetWindowID 停在 get_app_state 时刻。用户在两次动作之间把窗口拖到另一块屏后, windowPointToGlobalPoint 与 makeVisualCursorTarget 仍按旧 frame 算点,overlay 因此留在窗口刚离开的那块屏,坐标点击与截图映射同样偏移;直到动作收尾的 refreshSnapshot 刷新缓存才恢复,观感就是“先留在旧屏、稍后又正常”。overlay 侧也没有任何跨进程窗口移动观察者。抢焦点与坐标陈旧不同源:unhide / activate / open -b / AXRaise / AXMain / AXFocused 已全部收敛在 window recovery 或 global 指针的 opt-in 之后,默认零激活,本次不新增激活路径。 行为变化: - 每次动作前用 CGWindowListCopyWindowInfo(.optionIncludingWindow) 重读目标窗口 几何(同窗口同尺寸仅移动 → 只 patch windowBounds,元素 frame 是窗口相对坐标; 尺寸变化或换了窗口 → 整份 refreshSnapshot;解析不到 → 保持原快照)。 - VisualCursorTarget 带上 screen-state 点与当时的窗口 frame,overlay 保存 CursorRestingAnchor;窗口移动/换屏后由 live frame 重推 tip 并立即落位。 - 新增只读 AXWindowMotionObserver(AXWindowMoved / AXWindowResized, OPEN_COMPUTER_USE_WINDOW_MOVE_WATCH=0 关闭)做事件式跟随;动作前另做兜底校验 “光标所在屏 == 目标窗口所在屏”,不一致就重算重画,两条路径都不隐藏光标。 验证:swift build 通过;swift test 231 tests / 1 skipped / 0 failures(基线 222/1/0,新增 CursorCrossScreenTests 9 个用例);./scripts/check-docs.sh 通过。
SKILL.md 是平台中立正文,只补环境变量与行为结论,不含本地路径或本地流程: - allow_window_recovery=true 会把目标窗口拉回原位且点击成功,但会激活该 App (frontmost 改变)从而打断用户前台,保持默认关闭。 - 显式 click_method="sky_click" 对页面内容区按钮安全(不报错、渲染进程 PID 不变、 frontmost 不变);导航/侧栏链接仍会打崩 Chromium 渲染进程(错误代码 5, 用工具栏重新加载恢复),导航走默认 AXPress。 - click_method="global" 会真实移动系统指针((1392.56, 991.32) → (-1050, 453.5), 跨屏后停留)并改变 frontmost,默认关闭,只在隔离实例验证。 - 窗口移动/换屏后 overlay 按实时 frame 重定位(-1453 → 256/259 → -1453, 未再次点击也已跟随);OPEN_COMPUTER_USE_WINDOW_MOVE_WATCH=0 可关闭观察者。 验证: - 仅改 skills/open-computer-use/SKILL.md(+2/-1 行),无源码改动。
Chromium 会把被滚动容器裁出视口的元素上报成退化帧(实测 x=344 y=87 w=42 h=1)。 elementNeedsScrollIntoView 旧实现把“退化几何”一律判为“不需要滚动”,于是 set_value 在 长 Web 表单里从不滚入视口:值写进去了,人的屏幕上却看不到——验收轮的 AI 代操作因此不可见。 - 有位置但宽/高 ≤ 1pt 的裁剪帧 → 视为需要滚动;nil/非有限值/无窗口矩形仍不滚。 - ensureElementVisible 增加 gated 诊断(OPEN_COMPUTER_USE_DEBUG_INPUT_FALLBACKS=1)。 - 测试覆盖退化帧、0×0 有位置帧、.zero、NaN 与既有窗口外场景。 验证:swift test 231 tests / 0 failures(1 skipped);装机后对屏外字段 set_value 实测视口 自动跟随(截图对比),字段值与既有变更计划未受影响。
问题: - Chromium + Radix 弹层打开时,组件把 portal 之外的文档 aria-hidden,Chromium 不再把这些 节点暴露到 AX 树,快照里页面被整体替换成只剩列表框;上层拿不到表单 element_index,只能 反复 get_app_state,真实案例里同一工具被连续调用 5-8 次。 - element_index 只在当次快照内有效,动作后索引漂移,工具面被迫 read -> act -> read。 变更: - 新增 PopupSnapshot.swift:从应用根收集焦点窗口未渲染的临时子树(popover/sheet/menu/ listbox/dialog,以及 floating/dialog/system dialog/system floating 子角色的 window; 排除菜单栏与最小化窗口),用同一 RenderContext 与延续索引渲染并追加到 "--- popup ---" 之后,一次读取同时拿到窗口内容与弹层选项;没有任何打开的弹层时输出与旧版逐字节一致, 焦点窗口自身就是弹层时改为追加它盖住的其它窗口。 - 背景被 app 从 AX 树整体隐藏(Radix aria-hidden 的实测形态:AXWebArea 只剩一个打开中的 弹层且焦点在弹层内)时无法取回节点,改为追加 "--- popup note ---":说明背景索引暂时不可 用、先处理弹层、随后用 selector 继续,而不是让上层把页面消失误判成需要反复重读。 - 新增 ElementSelector.swift:selector 支持 role[name=...] / [name=...] / [role=...][name=...] / 裸名字;role 匹配 AX 角色、去 AX 写法、别名 family 与快照里的 本地化 role 文本;name 依次匹配 title/description/value/identifier/placeholder,先精确 再唯一前缀;按渲染父子链折叠 Chromium 的 wrapper + 文本叶子重复上报;未命中列出最近候选、 多处命中列出全部候选、语法错误直接报错,一律 fail closed。 - click / set_value 增加可选 selector(与 element_index 二选一,同时传报错);选择器在动作 执行的瞬间重新渲染树再解析,element_index 的语义与缓存快照保持不变。 - ElementRecord 增加 title/label/value/roleText/placeholder/parentIndex;MCP instructions 与 skill usage 文档补充弹层段落与选择器用法;get_app_state 的官方描述文案保持不变。 验证:swift build Build complete;swift test 257 tests / 1 skipped / 0 failures (231 -> 257,新增 PopupSnapshotTests 11 + ElementSelectorTests 15); ./scripts/check-docs.sh 通过。未做真机复验(硬约束:不重装/不重启正在运行的验收产物)。
人工验收期间在 Chromium + Radix 上实测两条: - 弹层打开时对背景元素用 selector 会 fail-closed 并列出候选(候选全是弹层项),重试无用, 必须先结束弹层交互:背景标题在弹层关闭前始终不可解析。 - `--- popup ---` 与 `--- popup note ---` 相互独立且可能同时缺席:Chromium listbox 的选项位于 web area 内时,web area 只剩该 listbox(选项完整含 selected),背景字段全部消失。该形态下 “选项可见”本身就是成功信号,不要因缺少标记判定读取失败。
实测教训:窗口被放在已不存在的副屏坐标(harness 日志已给 readback differs, not retried)时, macOS 只给浏览器 chrome、不给 web area(HTML 内容 缺失);同时软件光标因落在所有屏幕之外, 被 screenStatePointToAppKitGlobalPoint 原样返回,画到了另一块屏上。 把顺序写成硬前置:查窗口矩形 → 不在活动屏幕就移过去 → 再读一次并要求目标子树存在 → 最后动作。 并记录两条事实:软件光标默认开启(OPEN_COMPUTER_USE_VISUAL_CURSOR=0 可关);真实指针仅在 OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1 时才会移动。
实测(人工验收期间):runner 把窗口放在记忆的副屏坐标,用户换屏后该屏幕不再覆盖此区域,macOS 只返回浏览器 chrome、不返回 web area;同时 screenStatePointToAppKitGlobalPoint 对不属于任何屏幕 的点原样返回坐标,软件光标被画到了另一块屏上,两个症状都没有任何显式提示。 - 新增纯函数:isPointOnActiveDisplay / windowIntersectsActiveDisplay / offDisplayWindowNote。 - AppSnapshot.renderedText 在窗口与所有活动屏幕无交集时追加 `--- display note --- …`; 窗口在屏幕上时输出逐字节不变。 - makeVisualCursorTarget(at:) 改为可选:点无法映射时返回 nil,调用方(含 debug-cursor)不再画光标。 验证:新增 OffDisplayWindowTests(5 例:点/窗口在屏内、离屏、无 bounds、无屏幕、光标目标跳过), 先 RED(函数未定义)后 GREEN;swift test 262 tests / 1 skipped / 0 failures。已 build+装机+重签 (旧 fa11198f → 新 718679e0)并重连。 live 限制:macOS/Chromium 会把窗口钳制回某块屏幕,无法通过移动窗口造出“不在任何屏幕”的状态; 该状态由屏幕被移除/休眠/重排引起。已用第二屏窗口复核“不产生误报”。
真机反馈:把窗口从主屏拖到内建屏后,软件光标仍留在主屏并"被控制着移动",直到整段动画 跑完才回到内建屏。上一轮 29934cf 已做到"通知 / 动作前兜底把光标瞬移回实时 frame",但症状 仍可复现,说明还有一条路径在覆盖它。 根因:SoftwareCursorOverlay.animateMove 是主线程上的同步忙循环,每帧按预先采样的路径写 光标位置。窗口在行进中被拖动时,AX 通知触发的 applyLiveWindowAnchor 瞬移会被循环的下一 帧按旧路径采样覆盖,于是光标继续飞向窗口已经离开的那块屏。 - SnapshotWindowGeometry 新增纯决策 cursorTravelMustAbort(startFrame:liveFrame:):实时 frame 变化即中止;读不到 frame(最小化 / 换 Space)不算变化,与 snapshotWindowReanchorAction 的语义一致。 - animateMove 每帧重读目标窗口 frame,命中即 applyLiveWindowAnchor 落到新 frame 后返回, 不再执行收尾的旧目标落点。 - 新增 2 个用例:纯决策边界;以及跑真实行进循环的端到端用例(frame 在起始帧与首帧之间 变化)。 - 同步 skills/open-computer-use/references/usage.md。 验证: - 反向证明:临时禁用修复后新用例失败,落点 (140,370) 说明光标仍在旧路径上;恢复后通过, 落点 (840,370)(由实时 frame 推导)。 - swift test:264 tests / 1 skipped / 0 failures;make check-docs 通过。 - 未做真机双屏复现:本机探测窗口(TextEdit / Finder)落在 OCU 驱动的 Space 之外,动作路径 报 cgWindowNotFound,无法在不干扰用户桌面的前提下复现。
Real-machine verification: this does not fix the reported bugI installed the build from this branch on the machine that reported the issue (two displays: 2048x1152 main + 1512x982 built-in) and ran a controlled A/B, driving a scratch Finder window with Baseline (abort disabled): the window was moved to the main display mid-action and the cursor ended on the built-in display at the stale target ( This branch: same result — the cursor still ended at Why. A temporary instrumentation of the travel loop shows the abort condition does fire, but far too late: The window reached the main display about 0.45s into the travel, yet the re-read returned the old frame for 83 consecutive frames (~1.3s — the whole travel). So the chain The frame signal this patch relies on therefore arrives after the travel has already finished, which is exactly why the cursor still finishes on the old screen. What should work instead. The accessibility move notification is timely: Marking this draft until it is re-verified on the same rig. The unit tests here remain valid — the pure decision, and the loop behaviour under an injected frame change — but they cannot catch the real-world timing of the frame source. |
Follow-up: the abort works, but a later placement re-applies the stale pointI iterated on this on the same rig. The notification-driven abort behaves exactly as intended, and the instrumentation shows why it is still not enough: So: the accessibility notification arrives while the travel is still running, the AX frame read is correct at that instant (and A real fix therefore has to invalidate the pending cursor target when the window moves, not only stop the travel — the recomputation belongs in the choreographer/service path, next to where the target is derived from the snapshot. I have not pushed that change; the branch is reverted to the reviewed commit and this PR stays draft until a change is verified on the rig. Two things worth keeping regardless, both measured here:
|
真机复现路径:窗口从内建屏拖到主屏的过程中触发动作,光标最终停在旧屏。上一提交的"行进 途中 frame 变化即中止"确实生效,但仍不够——原因有二,本轮都补齐: 1. **判据信号太晚**:`CGWindowListCopyWindowInfo` 在窗口移动后仍返回旧 frame(实测连续 83 帧 ≈1.3s 不变),只有 AX 元素位置是即时的。改用 `kAXPositionAttribute` 优先读取, 窗口列表仅作兜底。 2. **覆盖中止结果的是动作管线自身**:`VisualInteractionChoreographer.approach` 是 `moveCursor(target)` → `settleCursorArrival(target)`,`settle` 用同一个"移动前算出的 target"再落位一次。现让 `settle` / `pulseClick` 落位前经 `liveTargetPoint(...)` 按实时 frame 重算(元素 frame 是窗口相对的:保留 window-local 偏移、只换原点)。 中止触发器也从轮询改为 **AX 移动通知**(generation 计数在 `targetWindowDidMove` 里自增): 通知在行进途中就会送达(`pumpFrame` 每帧跑 run loop),是唯一及时的信号。 验证: - 新增 3 个用例:通知中止行进、settle 与 pulseClick 用实时 frame;全套 267 tests / 1 skipped / 0 failures。 - 反向证明:去掉 settle 的实时重算后该用例失败,落点 (140,370) 即旧屏。 - **真机连续三次实测通过**:窗口拖到主屏后光标停在主屏 x=1023(窗口范围 600–1118); 对照版本(仅窗口列表兜底)在同一场景下停在旧屏 -977。 - `make check-docs` 通过。
Fixed and verified on the reporting machineThe earlier failures here were partly a false negative: another session was driving Open Computer Use at the same time. After a passive 10-second check that the overlay was not moving on its own, the scenario was re-run cleanly. Result: three consecutive passes. Window dragged from the built-in display to the main display during the action → the cursor ends on the main display at Two things were still missing after the first commit here. Both are now in:
The abort trigger is now the accessibility move notification (a generation counter bumped in Tests: three new cases — notification abort mid-travel, Still not covered: a real pointer drag (button held) was not reproduced end to end — the window move is scripted, so the frame-change timing is representative but not identical. |
把"默认路径不抢焦点、只有三条 opt-in 路径会"写进 usage 参考:三条路径分别由 `allow_window_recovery` 与 `click_method: "global"` 启用;同时注明目标 App 也可能因为 AX 动作 自行前置(Chromium 即如此),这不是客户端设置能阻止的。 给出双屏机器上的验收方法(被动采样前台 App + 从 CLI 并行触发动作,避免与真人操作互相阻塞), 并明确一条测试纪律:**不要用合成键盘事件模拟"用户正在输入"**——注入必然进入当时的前台 App, 也就是真实桌面,猜错窗口就会打到用户自己的应用里。只有真人打字,agent 只做测量。 验证:本节内容来自 2026-09-15 的双屏实测——默认路径下点击与键入期间前台 App 全程无变化, 目标输入框内容恰好等于探针字符串,操作者确认无丢字;原始数据与测量命令见 PR iFurySt#71 的讨论。
|
Added a documentation commit (
It ends with the two-display verification recipe used here, including one testing discipline worth writing down: never synthesize the "user is typing" side of the test. Injected key events go to whatever is frontmost at that instant, which is the real desktop; while building this verification an emulation harness guessed the wrong window and typed a 45-character probe into the user's own chat input. Only a human types; the agent only measures. Measured on the reporting machine (two displays, default settings, no
|
Summary
Real-machine report: after dragging a window from the main display to the built-in one, the software cursor stayed on the main display and kept being animated there, and only returned to the built-in display once the whole animation had finished.
29934cf(in #69) already re-places the cursor onto the live window frame, from both the accessibility move notification and the pre-action fallback. The symptom still reproduced, so something else was overriding that re-placement.Root cause:
SoftwareCursorOverlay.animateMoveruns a synchronous main-thread loop that writes the cursor on every frame from a path sampled up front. When the window is dragged during a travel, the notification-driven re-place is overwritten by the loop's next frame, which keeps writing samples towards the screen the window just left — which is exactly why the cursor looked like it was being driven across the old display.Fix
SnapshotWindowGeometrygains a pure decision,cursorTravelMustAbort(startFrame:liveFrame:): abort as soon as the live frame changed. An unreadable frame (minimized, hidden, another Space) is not a change, matchingsnapshotWindowReanchorAction, so the accessibility action paths keep working.animateMovere-reads the target window frame on every frame; on a change it lands the cursor on the live frame and returns, instead of finishing the stale path.Verification
(140, 370), still on the stale path; with the fix it passes at(840, 370), derived from the live frame.swift test: 264 tests / 1 skipped / 0 failures.make check-docspasses.Stacking
This branch is based on #69 (
feat/quiet-mode-scroll-into-view) because the cross-screen cursor code it fixes only exists there. Review the last commit only (fix(overlay): …); the earlier commits belong to #69, which should merge first.Not covered
cgWindowNotFound), so the loop behaviour is pinned by an injected frame sequence instead.