Skip to content

feat(platform-macos): WindowChangeDetector + FocusGuard per-action focus suppression (#1526) - #1531

Merged
f-trycua merged 7 commits into
mainfrom
feat/cua-driver-rs-focus-guard-window-detector
May 16, 2026
Merged

feat(platform-macos): WindowChangeDetector + FocusGuard per-action focus suppression (#1526)#1531
f-trycua merged 7 commits into
mainfrom
feat/cua-driver-rs-focus-guard-window-detector

Conversation

@f-trycua

@f-trycua f-trycua commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports Swift's WindowChangeDetector and FocusGuard.withFocusSuppressed per-action focus-suppression machinery to cua-driver-rs, building on the focus-steal infrastructure that landed in #1524.

Closes #1526.

  • New crates/platform-macos/src/window_change_detector.rs — snapshot the visible window set + frontmost pid, arm a wildcard focus_steal::begin_suppression lease, diff after the action, return a result suffix that matches Swift's wording verbatim so MCP callers don't need per-binary special cases.
  • New crates/platform-macos/src/focus_guard.rs — async closure helper that arms a targeted suppressor across the AX dispatch and sleeps ~50ms post-action so any reflex activation is observed before the lease drops.
  • 7 action tools (click, type_text, hotkey, press_key, drag, scroll, set_value) wrapped in the snapshot → suppressed-action → detect cycle. Success messages now suffix any observed side-effects.
  • PARITY.md updated with a new "Per-action focus suppression" section and the relevant tool rows flipped to VERIFIED.

Layering

Swift's FocusGuard does three things: (1) AX enablement assertion, (2) synthetic-focus write+restore on the enclosing window+element, (3) reactive suppressor. This Rust port ships layer 3 only — layers 1+2 are deferred because the AX assertion + attribute-write plumbing isn't yet ported. Empirically the layer-3 reactive guard combined with WindowChangeDetector's wildcard lease catches the majority of side-effects on real-world workflows. Documented in PARITY.md so the gap is auditable.

Commits

  1. 0e2d9e72 feat(platform-macos): WindowChangeDetector module
  2. 9ef085e2 feat(platform-macos): FocusGuard with_focus_suppressed helper
  3. 7784e131 feat(tools): wrap action tools in focus suppression + change detection
  4. 77d3ed95 docs(parity): per-action focus suppression section + flip wired tools

Test plan

  • cargo build --release clean (no new warnings vs main).
  • cargo test -p platform-macos — 20/20 passing (was 8/8 on main; +8 WindowChangeDetector tests, +4 FocusGuard tests).
  • ./run_tests.sh --parity -v from tests/integration/ — identical pass/fail count vs main (25 failures / 7 errors, all pre-existing environmental: missing Swift binary path, focus-grabber on host, etc.; no focus-suppression regressions).
  • Live smoke test — click on a focus-grabbing button in a backgrounded Chrome/Safari and confirm the result text suffix mentions any newly-opened window (manual; requires a host without the focus-grabber that's currently blocking test_focus_steal_parity on this runner).

Notes

  • Per coordinator update mid-task, Phase 4 was reduced to PARITY.md updates + parity-suite regression check. The originally-planned new integration test cases (per-action focus-steal parity) are dropped; the existing test_focus_steal_parity.py already covers the launch path and the new unit tests pin the new modules.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Expanded focus-steal prevention mechanism to click, key presses, hotkeys, drag, scroll, and text input actions on macOS
    • Added automatic detection and reporting of window changes triggered by actions
  • Documentation

    • Updated coverage documentation to reflect verified focus-suppression support for additional macOS action tools

Review Change Stack

@vercel

vercel Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Ignored Ignored Preview May 16, 2026 10:19pm

Request Review

@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 22b38637-22fd-41f9-af91-95fd004133cd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements per-action focus suppression by porting Swift's FocusGuard and WindowChangeDetector to Rust and integrating them into 7 macOS action tools. The infrastructure wraps individual AX actions with targeted suppression leases and change detection, emitting window-change summaries in tool results to signal unexpected side-effect activations.

Changes

Per-action focus suppression infrastructure and tool integration

Layer / File(s) Summary
Focus suppression wrapper and module exports
libs/cua-driver-rs/crates/platform-macos/src/focus_guard.rs, libs/cua-driver-rs/crates/platform-macos/src/lib.rs
with_focus_suppressed and with_focus_suppressed_now async closures conditionally arm targeted suppression leases and perform a 50ms post-action sleep before RAII teardown. Module exports are added to lib.rs.
Window change detection infrastructure
libs/cua-driver-rs/crates/platform-macos/src/window_change_detector.rs
WindowChangeDetector::snapshot() captures visible window IDs and frontmost PID, arms wildcard suppression, and Snapshot::detect_async() polls for new windows or foreground changes. Changes::result_suffix() formats detected changes matching Swift wording for tool result text.
Action tool integration with focus suppression and change detection
libs/cua-driver-rs/crates/platform-macos/src/tools/click.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/type_text.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/hotkey.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/press_key.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/drag.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/scroll.rs, libs/cua-driver-rs/crates/platform-macos/src/tools/set_value.rs
All 7 tools now follow the same pattern: capture prior frontmost PID, snapshot window state before action, run the blocking AX operation inside focus_guard::with_focus_suppressed, await async change detection, and append changes.result_suffix() to success messages.
Architecture documentation update
libs/cua-driver-rs/PARITY.md
Tool audit entries for click, press_key, and hotkey are marked "VERIFIED" with focus-suppression wraps; the "Focus-steal prevention" section now documents that FocusGuard (layer 3) and WindowChangeDetector span both launch_app and 7 per-action tools, with janitor using tokio::sync::watch.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • trycua/cua#1477: Implements the same core mechanism for macOS action side-effects—wrapping UI actions with a focus-steal suppression lease plus a window-change detector that snapshots visible windows/front PID and appends window-change result suffixes to tool output.

Poem

🐰 Hop, click, and type with quiet grace,
No windows leap from place to place,
Guards suppress the restless side-effects,
While detectors catch what comes next.
Rust now speaks what Swift once penned!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: porting WindowChangeDetector and FocusGuard per-action focus suppression to Rust for the macOS platform.
Linked Issues check ✅ Passed The PR implements all core objectives from #1526: WindowChangeDetector with snapshot/detect_async, FocusGuard with targeted suppression, integration into 7 tools with result suffix appending, and unit tests.
Out of Scope Changes check ✅ Passed All changes are within scope of issue #1526: focus-suppression infrastructure porting, tool integration, PARITY.md documentation, and tests. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cua-driver-rs-focus-guard-window-detector

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@libs/cua-driver-rs/crates/platform-macos/src/tools/press_key.rs`:
- Around line 103-133: The AX pre-focus call (the earlier focus_element() step)
must be moved inside the FocusGuard/WindowChangeDetector wrapper so the focus
write is suppressed and its side-effects are captured: when handling the
window_id + element_index path inside the closure passed to
focus_guard::with_focus_suppressed (the async spawn_blocking block created for
press_key.CGEvent after WindowChangeDetector::snapshot()), perform the AX focus
write there before calling crate::input::keyboard::press_key (and before the
NSMenu branch that calls
crate::input::skylight::with_menu_shortcut_activation/press_key_no_auth); ensure
the focus_element invocation uses the same pid, window_id and element_index
values and runs inside that guarded closure so WindowChangeDetector will observe
any resulting window changes.

In `@libs/cua-driver-rs/crates/platform-macos/src/window_change_detector.rs`:
- Around line 168-186: The snapshot() function currently re-reads
apps::frontmost_pid() (front_pid) and uses it to create the suppression lease
via focus_steal::begin_suppression, which can differ from the prior_front
captured by callers; change the API to accept the caller-captured PID (e.g., add
snapshot_with_front_pid(prior_front: Option<u32>) or an overload) and use that
value when arming the wildcard lease instead of re-calling
apps::frontmost_pid(); update callers that already capture prior_front to pass
it into snapshot_with_front_pid so both leases use the identical captured PID
and avoid timing-dependent restoration.

In `@libs/cua-driver-rs/PARITY.md`:
- Around line 1421-1422: The markdown contains code spans with trailing spaces
(`\, ` and `\; `) that trigger MD038; replace those code spans so they contain
only the punctuation characters (use `,` and `;`) and move any spacing
description into the surrounding prose (e.g., say "joined with ', ' (comma
followed by a space)" instead of embedding the space inside the backticks);
update the text that currently reads with `` `, ` `` and `` `; ` `` to use ```,`
`` and ``;`` and add a short prose note describing that items are joined with a
comma+space or semicolon+space as appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0941f6e3-59c7-413f-8287-eeb903ae058d

📥 Commits

Reviewing files that changed from the base of the PR and between 63be0af and 77d3ed9.

📒 Files selected for processing (11)
  • libs/cua-driver-rs/PARITY.md
  • libs/cua-driver-rs/crates/platform-macos/src/focus_guard.rs
  • libs/cua-driver-rs/crates/platform-macos/src/lib.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/click.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/drag.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/hotkey.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/press_key.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/scroll.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/set_value.rs
  • libs/cua-driver-rs/crates/platform-macos/src/tools/type_text.rs
  • libs/cua-driver-rs/crates/platform-macos/src/window_change_detector.rs

Comment on lines +103 to +133
// ── Focus-suppression wrap (Swift WindowChangeDetector + FocusGuard) ──
// Single-key presses can fire autocomplete (Return on a search
// box opens a results popover) or trigger menu shortcuts that
// open windows. Wrapping mirrors the hotkey path.
let prior_front = apps::frontmost_pid();
let snapshot = WindowChangeDetector::snapshot();

let result = focus_guard::with_focus_suppressed(
Some(pid),
prior_front,
"press_key.CGEvent",
|| async move {
tokio::task::spawn_blocking(move || {
let m: Vec<&str> = modifiers.iter().map(String::as_str).collect();
if let Some(wid) = window_id {
if element_index.is_none() {
// NSMenu path: window_id set but no element_index.
crate::input::skylight::with_menu_shortcut_activation(pid as libc::pid_t, wid, || {
crate::input::keyboard::press_key_no_auth(pid, &key, &m)
})?;
return Ok(());
}
}
crate::input::keyboard::press_key(pid, &key, &m)
})
.await
},
)
.await;

let changes = snapshot.detect_async().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include the AX pre-focus step in this guarded window.

This wrapper starts after the optional focus_element() call at Lines 93-101. In the window_id + element_index path, that AX focus write is the step most likely to activate the target or surface focus-driven UI, but it currently happens outside both FocusGuard and WindowChangeDetector. That means this path can still miss the exact side effect the new suppression machinery is supposed to catch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/crates/platform-macos/src/tools/press_key.rs` around lines
103 - 133, The AX pre-focus call (the earlier focus_element() step) must be
moved inside the FocusGuard/WindowChangeDetector wrapper so the focus write is
suppressed and its side-effects are captured: when handling the window_id +
element_index path inside the closure passed to
focus_guard::with_focus_suppressed (the async spawn_blocking block created for
press_key.CGEvent after WindowChangeDetector::snapshot()), perform the AX focus
write there before calling crate::input::keyboard::press_key (and before the
NSMenu branch that calls
crate::input::skylight::with_menu_shortcut_activation/press_key_no_auth); ensure
the focus_element invocation uses the same pid, window_id and element_index
values and runs inside that guarded closure so WindowChangeDetector will observe
any resulting window changes.

Comment on lines +168 to +186
pub fn snapshot() -> Snapshot {
let window_ids: HashSet<u32> = windows::visible_windows()
.into_iter()
.filter(|w| w.layer == 0)
.map(|w| w.window_id)
.collect();
let front_pid = apps::frontmost_pid();

// Arm wildcard suppression — covers snapshot → detect window.
// restore_to = current frontmost; target = wildcard (any other pid).
// If there's no frontmost (rare — screensaver, login window), we
// skip the lease; foreground-change tracking still runs.
let lease = front_pid.map(|restore_to| {
focus_steal::begin_suppression(
None, // wildcard
restore_to,
"WindowChangeDetector.snapshot",
)
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the same captured frontmost PID for both leases.

Each tool already captures prior_front immediately before calling snapshot(), but snapshot() re-queries apps::frontmost_pid() and arms the wildcard lease against that second value. If the frontmost app flips in that gap, the targeted lease and wildcard lease restore different pids, so focus restoration becomes timing-dependent in the exact path meant to make it deterministic.

Proposed direction
 impl WindowChangeDetector {
+    pub fn snapshot_with_front_pid(front_pid: Option<i32>) -> Snapshot {
+        let window_ids: HashSet<u32> = windows::visible_windows()
+            .into_iter()
+            .filter(|w| w.layer == 0)
+            .map(|w| w.window_id)
+            .collect();
+
+        let lease = front_pid.map(|restore_to| {
+            focus_steal::begin_suppression(
+                None,
+                restore_to,
+                "WindowChangeDetector.snapshot",
+            )
+        });
+
+        Snapshot {
+            window_ids,
+            front_pid,
+            _lease: lease,
+        }
+    }
+
     pub fn snapshot() -> Snapshot {
-        let window_ids: HashSet<u32> = windows::visible_windows()
-            .into_iter()
-            .filter(|w| w.layer == 0)
-            .map(|w| w.window_id)
-            .collect();
-        let front_pid = apps::frontmost_pid();
-
-        let lease = front_pid.map(|restore_to| {
-            focus_steal::begin_suppression(
-                None, // wildcard
-                restore_to,
-                "WindowChangeDetector.snapshot",
-            )
-        });
-
-        Snapshot {
-            window_ids,
-            front_pid,
-            _lease: lease,
-        }
+        Self::snapshot_with_front_pid(apps::frontmost_pid())
     }
 }

Then have the tool wrappers pass their already-captured prior_front into snapshot_with_front_pid(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/crates/platform-macos/src/window_change_detector.rs`
around lines 168 - 186, The snapshot() function currently re-reads
apps::frontmost_pid() (front_pid) and uses it to create the suppression lease
via focus_steal::begin_suppression, which can differ from the prior_front
captured by callers; change the API to accept the caller-captured PID (e.g., add
snapshot_with_front_pid(prior_front: Option<u32>) or an overload) and use that
value when arming the wildcard lease instead of re-calling
apps::frontmost_pid(); update callers that already capture prior_front to pass
it into snapshot_with_front_pid so both leases use the identical captured PID
and avoid timing-dependent restoration.

Comment thread libs/cua-driver-rs/PARITY.md Outdated
Comment on lines +1421 to +1422
(multiple windows grouped by app, titles in quotes, joined with `, `;
multiple apps joined with `; `; alphabetical by app name).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix markdownlint MD038 violations in code spans.

Line 1421 and Line 1422 use code spans with trailing spaces (`, `, `; `), which triggers MD038. Use ,/; without embedded trailing spaces and describe spacing in prose.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 1421-1421: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 1422-1422: Spaces inside code span elements

(MD038, no-space-in-code)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/PARITY.md` around lines 1421 - 1422, The markdown contains
code spans with trailing spaces (`\, ` and `\; `) that trigger MD038; replace
those code spans so they contain only the punctuation characters (use `,` and
`;`) and move any spacing description into the surrounding prose (e.g., say
"joined with ', ' (comma followed by a space)" instead of embedding the space
inside the backticks); update the text that currently reads with `` `, ` `` and
`` `; ` `` to use ```,` `` and ``;`` and add a short prose note describing that
items are joined with a comma+space or semicolon+space as appropriate.

f-trycua added a commit that referenced this pull request May 16, 2026
…losure

press_key and scroll were pre-focusing the target AX element BEFORE the
focus_guard::with_focus_suppressed wrapper and before
WindowChangeDetector::snapshot. Any reflex activations triggered by the
AXSetAttribute(kAXFocused) write itself (Chromium tab activation on
focus, autocomplete popovers) escaped both the wildcard snapshot
suppressor and the targeted FocusGuard lease.

Move the focus_element() spawn_blocking + settle inside the closure
passed to with_focus_suppressed so the AX write runs under the same
suppression umbrella as the keystroke that follows. Cache lookup stays
outside (no side-effects).

Spotted by CodeRabbit on PR #1531.
f-trycua added a commit that referenced this pull request May 16, 2026
…or::snapshot

snapshot() previously called apps::frontmost_pid() internally to seed
the wildcard suppression lease's restore_to. Every action-tool caller
ALREADY captured prior_front one line earlier for its surrounding
focus_guard::with_focus_suppressed lease — and between the caller's
read and the detector's own a different app could become frontmost,
leaving the wildcard lease and the targeted FocusGuard lease pointing
at different pids to restore to.

Change snapshot() to take prior_front: Option<i32> and use it both as
the lease's restore_to and as the recorded front_pid for change
detection. Update all 7 wired tools (8 call sites — click has two)
to thread the value they already had.

Add snapshot_stores_caller_prior_front regression test pinning the
round-trip behaviour.

Spotted by CodeRabbit on PR #1531.
f-trycua added a commit that referenced this pull request May 16, 2026
…ter code spans

The "joined with \`, \`" / "joined with \`; \`" code spans included a
trailing space inside the backticks to convey "the join string is a
comma + space", but markdownlint MD038 flags whitespace inside code
spans. Rewrite the spans to hold only the punctuation character and
spell out the trailing space in the surrounding prose.

Spotted by CodeRabbit on PR #1531.
@f-trycua

Copy link
Copy Markdown
Collaborator Author

Pushed fixup commits for the 3 CodeRabbit findings:

  • 7214abef — fix M1 Pro not supported? #1: run AX focus_element() inside the focus_guard::with_focus_suppressed closure in press_key and scroll (the two tools that pre-focus before their CGEvent action), so the AX write's reflex activations are caught by the wildcard snapshot suppressor + targeted FocusGuard lease. Grep across the other wired tools confirms only these two had the out-of-closure pattern; type_text_chars pre-focuses too but doesn't run under the snapshot wrapper so it was out of scope.
  • 8df85460 — fix Unable to run an image if download previously failed due to system running out of space #2: change WindowChangeDetector::snapshot() to take prior_front: Option<i32> from the caller (instead of re-reading via apps::frontmost_pid() inside) so the wildcard lease's restore_to matches what the surrounding focus_guard lease saw. Updated all 8 call sites (click has two — AX path + pixel path; plus drag, hotkey, press_key, scroll, set_value, type_text). Added snapshot_stores_caller_prior_front regression test — cargo test -p platform-macos now 21/21.
  • 751f2dba — fix Fix README typo: Sequoia not Sonoma #3: rewrite the MD038 code spans in PARITY.md so they hold only the punctuation character (`,`, `;`) with the "followed by a space" spelled out in prose.

cargo build --release clean, cargo test -p platform-macos 21/21 (was 20/20 + new regression test).

@coderabbitai please re-review.

@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the 3 fixup commits
git log --oneline -10

echo "=== Commit 7214abef ==="
git show 7214abef --stat

echo "=== Commit 8df85460 ==="
git show 8df85460 --stat

echo "=== Commit 751f2dba ==="
git show 751f2dba --stat

Length of output: 4405


🏁 Script executed:

#!/bin/bash
# Check current state of press_key.rs and scroll.rs for fix `#1`
echo "=== press_key.rs ==="
cat libs/cua-driver-rs/crates/platform-macos/src/tools/press_key.rs

echo ""
echo "=== scroll.rs ==="
cat libs/cua-driver-rs/crates/platform-macos/src/tools/scroll.rs

Length of output: 12857


🏁 Script executed:

#!/bin/bash
# Check WindowChangeDetector snapshot signature and all call sites for fix `#2`
echo "=== window_change_detector.rs snapshot fn ==="
rg -n "fn snapshot" libs/cua-driver-rs/crates/platform-macos/src/window_change_detector.rs -A 10

echo ""
echo "=== All snapshot() call sites ==="
rg -n "snapshot\(" libs/cua-driver-rs/crates/platform-macos/src/ -B2 -A2

echo ""
echo "=== regression test ==="
rg -n "snapshot_stores_caller_prior_front" libs/cua-driver-rs/crates/platform-macos/src/ -A 20

Length of output: 11042


🏁 Script executed:

#!/bin/bash
# Check PARITY.md for fix `#3` - MD038 code spans
rg -n "\`" libs/cua-driver-rs/PARITY.md | head -60

Length of output: 3954


🏁 Script executed:

#!/bin/bash
# Check the specific context around the MD038 fix in PARITY.md
git show 751f2dba -- libs/cua-driver-rs/PARITY.md

Length of output: 1461


@f-trycua All three fixes look correct after re-review:

  • Fix #1 (7214abef): Verified in both press_key.rs and scroll.rsfocus_element() + the 30 ms settle are now inside the with_focus_suppressed closure, while the cache-only get_element_ptr lookup remains outside. The pattern is consistent across both files.

  • Fix #2 (8df85460): WindowChangeDetector::snapshot(prior_front) signature confirmed, and all 8 call sites verified (click ×2 — AX path + pixel path, drag, hotkey, press_key, scroll, set_value, type_text). The snapshot_stores_caller_prior_front regression test correctly pins the round-trip for both Some(bogus_pid) and None, catching any silent fallback to a live re-read.

  • Fix #3 (751f2dba): The MD038 code spans are correctly rewritten — `, ``,` with "comma followed by a space" in prose, and `; ``;` with "semicolon followed by a space" in prose.

All three findings are cleanly resolved. 21/21 tests passing with the added regression. LGTM — approving.

[approve]

f-trycua and others added 7 commits May 17, 2026 00:18
Ports Swift's WindowChangeDetector (libs/cua-driver/Sources/CuaDriverServer/
Tools/WindowChangeDetector.swift) to Rust. Snapshot the visible window
set + frontmost pid before an action, diff after, return a one-line
result suffix that matches Swift's wording verbatim so MCP callers
don't have to special-case the binary.

The Snapshot owns an Option<SuppressionLease> (wildcard target_pid=None,
restore_to = current frontmost) — dropping the Snapshot ends the lease,
covering the full snapshot→action→detect window. This catches fast
self-activations before the poll loop can observe them.

Unit tests cover the pure-function diff (opened/closed) plus all five
result_suffix branches (no-change, single titled window, grouped by app,
empty title, foreground-only change). 16/16 passing (was 8/8 baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports Swift's FocusGuard.withFocusSuppressed layer-3 reactive
suppressor (libs/cua-driver/Sources/CuaDriverCore/Focus/FocusGuard.swift)
to Rust as a closure-based async wrapper. Per-action call sites (click,
type_text, set_value, drag) wrap their AX dispatch in this helper to
catch the case where an AX attribute write triggers a reflexive
self-activation in the target app.

Behavior matches Swift:
- Skip arming when target == prior_frontmost (self → self short-circuit)
- Skip arming when prior_frontmost is None (no app to restore to)
- 50ms post-action settle before dropping the lease, giving any
  in-flight focus-grab reflex time to fire and be observed

Layers 1+2 (AXManualAccessibility / synthetic-focus write+restore) are
deliberately deferred to a follow-up — they require AX assertion +
attribute write/restore plumbing not yet ported and empirically the
layer-3 guard combined with WindowChangeDetector's wildcard catches the
majority of side-effects on real-world workflows. PARITY.md will
document the gap in phase 4.

Also adds a `with_focus_suppressed_now` convenience that resolves
prior_frontmost at call time for callers without a surrounding snapshot.

Tests: 4 new under focus_guard::tests covering the arm/skip branches
and the now-variant. 20/20 platform-macos passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the 7 macOS action tools (click, type_text, hotkey, press_key,
drag, scroll, set_value) in the WindowChangeDetector + FocusGuard
helpers shipped in the previous two commits, mirroring the Swift
ClickTool / TypeTextTool / SetValueTool pattern.

Per-tool integration is identical:

  let prior   = apps::frontmost_pid();
  let snapshot = WindowChangeDetector::snapshot();   // arms wildcard
  let result  = focus_guard::with_focus_suppressed(
                    Some(pid), prior, "<origin>", || async { ... }
                ).await;
  let changes = snapshot.detect_async().await;       // drops wildcard
  // append changes.result_suffix() to success text

ClickTool covers both AX (element_index path) and pixel paths — both
can land on focus-grabbing UI. PressKeyTool and HotkeyTool wrap their
CGEvent paths because Cmd+N / Return-on-autocomplete can open new
windows. DragTool wraps because drag-and-drop on the Dock or a
background app icon can spawn helper windows. ScrollTool wraps for
parity (rare side-effects but cheap to add).

Each tool's success message now suffixes any window/foreground
side-effects observed during the snapshot→detect cycle. Wording
matches Swift verbatim ("Action opened new window(s): X (\"title\")."
or "Action caused a different app to become frontmost.") so MCP
callers don't need per-binary special cases.

Also adds Snapshot::detect_async() so callers don't block the tokio
runtime on the up-to-1s poll loop. The synchronous detect() remains
for tests and other blocking call sites.

Build clean (release). 20/20 platform-macos tests pass; api_parity
suite shows identical pass/fail count vs main (25/7 pre-existing
failures, all unrelated to focus suppression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the WindowChangeDetector + FocusGuard port (#1526) in
PARITY.md:

- New "Per-action focus suppression" section under the existing
  Focus-steal block, with snapshot→action→detect cycle diagram,
  verbatim result-suffix wording, and the layer-1+2 deferral notes.
- click / press_key / hotkey rows flipped from "(TBD)" / "OPEN" to
  VERIFIED for the focus-suppression wrap with cross-refs to the new
  section.
- Focus-steal intro reworded — no longer "slated for use", now
  "used by `launch_app` and by the 7 action tools".
- "Intentional simplifications" updated to reflect that
  WindowChangeDetector has shipped and FocusGuard ships layer-3 only.

Per coordinator instruction (Phase 4 update): the new integration
test cases are dropped. The existing parity suite was confirmed not
to regress after Phase 3 — identical pass/fail count vs main
(25 failures / 7 errors, all pre-existing environmental:
test_mcp_set_recording_enable_disable, test_mcp_screenshot_has_png_image,
missing Swift binary path, etc.; no focus-suppression-related
regressions).

Closes #1526.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…losure

press_key and scroll were pre-focusing the target AX element BEFORE the
focus_guard::with_focus_suppressed wrapper and before
WindowChangeDetector::snapshot. Any reflex activations triggered by the
AXSetAttribute(kAXFocused) write itself (Chromium tab activation on
focus, autocomplete popovers) escaped both the wildcard snapshot
suppressor and the targeted FocusGuard lease.

Move the focus_element() spawn_blocking + settle inside the closure
passed to with_focus_suppressed so the AX write runs under the same
suppression umbrella as the keystroke that follows. Cache lookup stays
outside (no side-effects).

Spotted by CodeRabbit on PR #1531.
…or::snapshot

snapshot() previously called apps::frontmost_pid() internally to seed
the wildcard suppression lease's restore_to. Every action-tool caller
ALREADY captured prior_front one line earlier for its surrounding
focus_guard::with_focus_suppressed lease — and between the caller's
read and the detector's own a different app could become frontmost,
leaving the wildcard lease and the targeted FocusGuard lease pointing
at different pids to restore to.

Change snapshot() to take prior_front: Option<i32> and use it both as
the lease's restore_to and as the recorded front_pid for change
detection. Update all 7 wired tools (8 call sites — click has two)
to thread the value they already had.

Add snapshot_stores_caller_prior_front regression test pinning the
round-trip behaviour.

Spotted by CodeRabbit on PR #1531.
…ter code spans

The "joined with \`, \`" / "joined with \`; \`" code spans included a
trailing space inside the backticks to convey "the join string is a
comma + space", but markdownlint MD038 flags whitespace inside code
spans. Rewrite the spans to hold only the punctuation character and
spell out the trailing space in the surrounding prose.

Spotted by CodeRabbit on PR #1531.
@f-trycua
f-trycua force-pushed the feat/cua-driver-rs-focus-guard-window-detector branch from 751f2db to 57359bc Compare May 16, 2026 22:19
@f-trycua
f-trycua merged commit a91a2fb into main May 16, 2026
4 of 5 checks passed
@f-trycua
f-trycua deleted the feat/cua-driver-rs-focus-guard-window-detector branch May 16, 2026 22:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cua-driver-rs: port WindowChangeDetector + FocusGuard per-action focus suppression

1 participant