Skip to content

feat(cua-driver): add drag tool for press-drag-release gestures - #1402

Merged
f-trycua merged 1 commit into
mainfrom
feat/cua-driver-drag-tool
Apr 27, 2026
Merged

feat(cua-driver): add drag tool for press-drag-release gestures#1402
f-trycua merged 1 commit into
mainfrom
feat/cua-driver-drag-tool

Conversation

@f-trycua

@f-trycua f-trycua commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a new drag MCP tool to cua-driver: pixel-addressed press-drag-release gesture between two window-local endpoints.
  • Pixel-only by design — macOS AX has no semantic drag action, so there is no element_index mode (unlike click/double_click). Address an element via get_window_state, read its bounds, pass pixel coordinates.
  • Covers marquee/lasso selection, drag-and-drop, slider scrubbing, resize handles, panel repositioning. Modifier keys (cmd/shift/option/ctrl) held across the gesture.
  • Routing: .cghidEventTap when target is frontmost (real cursor traces the path — required for AppKit drag sources / canvas viewports); auth-signed pid-routed path when backgrounded (cursor-neutral).
  • Knobs: duration_ms (default 500), steps (default 20), button (left/right/middle), from_zoom for zoom-image coordinates.
  • Docs updated: mcp-tools.mdx, cli-reference.mdx, SKILL.md. Registered in ToolRegistry action list.

Test plan

  • swift build clean in libs/cua-driver
  • pytest libs/cua-driver/Tests/integration/test_drag_slider_delivery.py — slider-scrub delivery test passes
  • Manual: marquee-select in Finder list view (frontmost path)
  • Manual: drag-and-drop a file between two Finder windows (frontmost path)
  • Manual: slider scrub with backgrounded target (pid-routed path)
  • Manual: option-drag duplicates / shift-drag constrains axis
  • Docs render correctly in fumadocs preview

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a new drag tool enabling mouse drag interactions from point A to point B with configurable timing, gesture steps, button type, and modifier keys.
  • Documentation

    • Documented the drag tool with parameter specifications and usage examples.
  • Tests

    • Added integration tests to verify drag functionality and gesture delivery.

Pixel-addressed drag primitive between two window-local endpoints. macOS
AX has no semantic drag action, so this is pixel-only by design — covers
marquee selection, drag-and-drop, slider scrub, resize handles, panel
repositioning. Routes via .cghidEventTap when frontmost (real cursor
traces the path; required for AppKit drag sources / canvas viewports)
and via the auth-signed pid-routed path when backgrounded.

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

vercel Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 27, 2026 2:27am

Request Review

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR introduces a new drag tool to the CUA driver, enabling synthesized drag-and-drop gestures with pixel-accurate coordinates. Changes span documentation, a new MouseInput.drag() API with configurable timing and steps, a DragTool handler managing coordinate conversion and cursor positioning, registry integration, and integration tests validating drag delivery on backgrounded windows.

Changes

Cohort / File(s) Summary
Documentation
docs/content/docs/cua-driver/reference/cli-reference.mdx, docs/content/docs/cua-driver/reference/mcp-tools.mdx, libs/cua-driver/Skills/cua-driver/SKILL.md
Added documentation for the new drag tool, including CLI inventory entry, MCP tool specification with parameter details (pid, from_x, from_y, to_x, to_y, plus optional parameters), and action mapping guidance.
Core Mouse Input API
libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift
Implemented new MouseInput.drag(from:to:toPid:button:durationMs:steps:modifiers:) method synthesizing drag gestures via step interpolation, HID event posting for frontmost windows, and NSEvent bridging for backgrounded windows.
Tool Implementation & Registry
libs/cua-driver/Sources/CuaDriverServer/Tools/DragTool.swift, libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift
Added DragTool handler with coordinate validation, window-local-to-screen conversion, cursor pinning/animation sequencing, and focus suppression. Extended ToolRegistry to include DragTool.handler and register "drag" in actionToolNames.
Integration Testing
libs/cua-driver/Tests/integration/test_drag_slider_delivery.py
Comprehensive integration test verifying pixel-accurate drag delivery on a Safari range slider when Safari is backgrounded, including FocusMonitorApp coordination, coordinate conversion, and focus-loss event verification.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A drag across the screen so fine,
From pixel A to pixel B in time,
The cursor dances, smooth and slow,
With steps and curves, a graceful show!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 change: introduction of a new drag tool for press-drag-release gestures in cua-driver. It is concise, specific, and clearly summarizes the primary feature addition across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cua-driver-drag-tool

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/content/docs/cua-driver/reference/mcp-tools.mdx (1)

437-437: ⚠️ Potential issue | 🟡 Minor

Recording section omits drag from the action-tool list.

Line 437 enumerates the action-tool calls captured by the trajectory recorder — but drag is now in ToolRegistry.actionToolNames (line 37 of ToolRegistry.swift) so it is recorded. The list here should be updated for consistency, otherwise users may not know drags participate in replay.

📝 Proposed fix
-The trajectory recorder captures every action-tool call (`click`, `right_click`, `scroll`, `type_text`, `type_text_chars`, `press_key`, `hotkey`, `set_value`) into numbered turn folders. Recordings can be replayed turn-by-turn.
+The trajectory recorder captures every action-tool call (`click`, `right_click`, `drag`, `scroll`, `type_text`, `type_text_chars`, `press_key`, `hotkey`, `set_value`) into numbered turn folders. Recordings can be replayed turn-by-turn.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/cua-driver/reference/mcp-tools.mdx` at line 437, The
documentation’s list of action-tool calls is missing the "drag" action even
though ToolRegistry.actionToolNames (and the trajectory recorder) includes it;
update the sentence that enumerates recorded actions to include `drag` alongside
`click`, `right_click`, `scroll`, `type_text`, `type_text_chars`, `press_key`,
`hotkey`, and `set_value` so the docs match the actual behavior of the
trajectory recorder and ToolRegistry.actionToolNames.
🧹 Nitpick comments (3)
libs/cua-driver/Sources/CuaDriverServer/Tools/DragTool.swift (1)

296-304: Summary string mislabels fromX/fromY/toX/toY as window-pixel coords when zoom/ratio is in effect.

When fromZoom == true, fromX/fromY/toX/toY are in the zoom image's pixel space, and when an ImageResizeRegistry.ratio is in play the inputs are in resized-image pixels. Calling them "window-pixel" in the summary is technically wrong for those cases — the actual window-local pixels are actualFromX/actualFromY/actualToX/actualToY.

Either log the post-translation values (actualFromX, etc.) or qualify the label so a reader debugging from logs isn't misled:

♻️ Proposed nit fix
-            let summary =
-                "Posted drag\(buttonSuffix)\(modSuffix) to pid \(pid) "
-                + "from window-pixel (\(Int(fromX)), \(Int(fromY))) "
-                + "→ (\(Int(toX)), \(Int(toY))), "
-                + "screen (\(Int(startScreen.x)), \(Int(startScreen.y))) "
-                + "→ (\(Int(endScreen.x)), \(Int(endScreen.y))) "
-                + "in \(durationMs)ms / \(steps) steps."
+            let summary =
+                "Posted drag\(buttonSuffix)\(modSuffix) to pid \(pid) "
+                + "from input (\(Int(fromX)), \(Int(fromY))) "
+                + "→ (\(Int(toX)), \(Int(toY))), "
+                + "window-pixel (\(Int(actualFromX)), \(Int(actualFromY))) "
+                + "→ (\(Int(actualToX)), \(Int(actualToY))), "
+                + "screen (\(Int(startScreen.x)), \(Int(startScreen.y))) "
+                + "→ (\(Int(endScreen.x)), \(Int(endScreen.y))) "
+                + "in \(durationMs)ms / \(steps) steps."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/DragTool.swift` around lines
296 - 304, The summary string incorrectly labels fromX/fromY/toX/toY as
"window-pixel" even when translations (fromZoom or ImageResizeRegistry.ratio)
mean those coordinates are in zoom/resized-image pixel space; update the message
built in the summary (the block that composes modSuffix, buttonSuffix and
summary) to either use the post-translation window-local coordinates
actualFromX/actualFromY/actualToX/actualToY or change the label to an accurate
qualifier (e.g. "image-pixel" or "window-local pixel (post-translation)") so the
logged coordinates reflect their true space; adjust the summary construction
where summary is defined to reference the correct variables or updated label.
libs/cua-driver/Tests/integration/test_drag_slider_delivery.py (1)

240-243: next(...) will raise StopIteration if window_id isn't in the list — a confusing failure mode in tests.

The window resolved on line 228 should always appear in list_windows, but if Safari closes/refocuses between the two driver calls, next here raises StopIteration rather than a useful test failure.

♻️ Proposed defensive nit
-            win = next(
-                w for w in windows
-                if w["window_id"] == window_id
-            )
+            win = next(
+                (w for w in windows if w["window_id"] == window_id),
+                None,
+            )
+            self.assertIsNotNone(
+                win, f"window_id {window_id} disappeared from list_windows"
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Tests/integration/test_drag_slider_delivery.py` around lines
240 - 243, The current use of next(...) to find the window may raise
StopIteration if the window_id is missing; change the lookup to use next((w for
w in windows if w["window_id"] == window_id), None) and then assert that the
result (win) is not None with a clear failure message (e.g., f"Expected
window_id {window_id} in list_windows but not found; windows={windows}") so the
test fails with a helpful assertion instead of StopIteration; update the code
around variables windows, window_id and win accordingly.
libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift (1)

468-473: Consider using nanosleep instead of usleep for portability.

POSIX forbids usleep with useconds_t ≥ 1,000,000 (undefined behavior), and with schema extremes (duration_ms=10000, steps=1), perStepUs reaches 10,000,000 µs. While modern Darwin tolerates this (delegating internally to nanosleep), it's a portability issue if this code runs on stricter POSIX systems. Using nanosleep directly or chunking the sleep would be cleaner. This only affects corner cases—defaults (duration_ms=500, steps=20) produce 25 ms/step, well within bounds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift` around lines
468 - 473, The calculation for per-step sleep uses perStepUs which can exceed
1,000,000 µs and later calls usleep (undefined per POSIX); change the sleep
strategy in the_mouse-drag path that uses clampedSteps / clampedDuration
(symbols: clampedSteps, clampedDuration, perStepUs) to either call nanosleep
with a timespec computed from perStepUs (seconds + nanoseconds) or break the
sleep into safe chunks ≤ 1_000_000 µs and loop, replacing any usleep(perStepUs)
call; ensure the new code handles interruptions (EINTR) by retrying the
remaining time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift`:
- Around line 468-473: The per-step pacing calculation in MouseInput (the
perStepUs computation) is off-by-one because the sequence does an extra sleep
before mouseUp, so the wall-clock budget should be split across clampedSteps+1
gaps, not clampedSteps; update the perStepUs formula to use (clampedSteps + 1)
when computing per-step microseconds, and apply the same change in
dragFrontmostViaHIDTap and its caller to ensure total duration_ms is honored (or
alternatively remove the trailing sleep before mouseUp in both places if you
prefer that approach).

---

Outside diff comments:
In `@docs/content/docs/cua-driver/reference/mcp-tools.mdx`:
- Line 437: The documentation’s list of action-tool calls is missing the "drag"
action even though ToolRegistry.actionToolNames (and the trajectory recorder)
includes it; update the sentence that enumerates recorded actions to include
`drag` alongside `click`, `right_click`, `scroll`, `type_text`,
`type_text_chars`, `press_key`, `hotkey`, and `set_value` so the docs match the
actual behavior of the trajectory recorder and ToolRegistry.actionToolNames.

---

Nitpick comments:
In `@libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift`:
- Around line 468-473: The calculation for per-step sleep uses perStepUs which
can exceed 1,000,000 µs and later calls usleep (undefined per POSIX); change the
sleep strategy in the_mouse-drag path that uses clampedSteps / clampedDuration
(symbols: clampedSteps, clampedDuration, perStepUs) to either call nanosleep
with a timespec computed from perStepUs (seconds + nanoseconds) or break the
sleep into safe chunks ≤ 1_000_000 µs and loop, replacing any usleep(perStepUs)
call; ensure the new code handles interruptions (EINTR) by retrying the
remaining time.

In `@libs/cua-driver/Sources/CuaDriverServer/Tools/DragTool.swift`:
- Around line 296-304: The summary string incorrectly labels fromX/fromY/toX/toY
as "window-pixel" even when translations (fromZoom or ImageResizeRegistry.ratio)
mean those coordinates are in zoom/resized-image pixel space; update the message
built in the summary (the block that composes modSuffix, buttonSuffix and
summary) to either use the post-translation window-local coordinates
actualFromX/actualFromY/actualToX/actualToY or change the label to an accurate
qualifier (e.g. "image-pixel" or "window-local pixel (post-translation)") so the
logged coordinates reflect their true space; adjust the summary construction
where summary is defined to reference the correct variables or updated label.

In `@libs/cua-driver/Tests/integration/test_drag_slider_delivery.py`:
- Around line 240-243: The current use of next(...) to find the window may raise
StopIteration if the window_id is missing; change the lookup to use next((w for
w in windows if w["window_id"] == window_id), None) and then assert that the
result (win) is not None with a clear failure message (e.g., f"Expected
window_id {window_id} in list_windows but not found; windows={windows}") so the
test fails with a helpful assertion instead of StopIteration; update the code
around variables windows, window_id and win accordingly.
🪄 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: 2f655d10-507f-4afc-8c6a-5a67252af4ce

📥 Commits

Reviewing files that changed from the base of the PR and between 46dbcb4 and 0d1713d.

📒 Files selected for processing (7)
  • docs/content/docs/cua-driver/reference/cli-reference.mdx
  • docs/content/docs/cua-driver/reference/mcp-tools.mdx
  • libs/cua-driver/Skills/cua-driver/SKILL.md
  • libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift
  • libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift
  • libs/cua-driver/Sources/CuaDriverServer/Tools/DragTool.swift
  • libs/cua-driver/Tests/integration/test_drag_slider_delivery.py

Comment on lines +468 to +473
let clampedSteps = max(1, min(200, steps))
let clampedDuration = max(0, min(10_000, durationMs))
// Split the wall-clock budget across the dragged-step gaps.
// `clampedSteps` intermediate points produce `clampedSteps`
// gaps between down → first-drag → … → last-drag → up.
let perStepUs = useconds_t((clampedDuration * 1_000) / clampedSteps)

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

Per-step pacing has an off-by-one vs the documented contract.

The comment on lines 470–472 says clampedSteps gaps, but the actual posting sequence is:

down → [sleep, drag]×N → sleep, up

That's N+1 sleeps of perStepUs each, so total wall time ≈ (N+1)/N × duration_ms. At the default N=20 it's a benign ~5% overshoot, but at the schema-allowed minimum steps=1 it's a full 2× overshoot — duration_ms=500, steps=1 lands as a 1 s drag.

Either drop the trailing sleep before mouseUp, or split the budget across N+1 gaps. The latter matches the comment more closely:

♻️ Proposed fix
-        let clampedSteps = max(1, min(200, steps))
-        let clampedDuration = max(0, min(10_000, durationMs))
-        // Split the wall-clock budget across the dragged-step gaps.
-        // `clampedSteps` intermediate points produce `clampedSteps`
-        // gaps between down → first-drag → … → last-drag → up.
-        let perStepUs = useconds_t((clampedDuration * 1_000) / clampedSteps)
+        let clampedSteps = max(1, min(200, steps))
+        let clampedDuration = max(0, min(10_000, durationMs))
+        // Split the wall-clock budget across all gaps:
+        //   down → drag₁ → … → dragₙ → up
+        // is `clampedSteps + 1` gaps. Without the +1 we overshoot
+        // `duration_ms` by one extra `perStepUs` (significant at low
+        // step counts).
+        let perStepUs = useconds_t((clampedDuration * 1_000) / (clampedSteps + 1))

Apply the same change in dragFrontmostViaHIDTap (line 549 / its caller).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift` around lines
468 - 473, The per-step pacing calculation in MouseInput (the perStepUs
computation) is off-by-one because the sequence does an extra sleep before
mouseUp, so the wall-clock budget should be split across clampedSteps+1 gaps,
not clampedSteps; update the perStepUs formula to use (clampedSteps + 1) when
computing per-step microseconds, and apply the same change in
dragFrontmostViaHIDTap and its caller to ensure total duration_ms is honored (or
alternatively remove the trailing sleep before mouseUp in both places if you
prefer that approach).

@f-trycua
f-trycua merged commit 5f314f7 into main Apr 27, 2026
8 of 9 checks passed
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.

1 participant