Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/content/docs/cua-driver/reference/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ The following MCP tools are callable via `cua-driver <tool>`. For input schemas
- `click` — left-click by `element_index` or `(x, y)`.
- `double_click` — double-click by `element_index` (AXOpen) or `(x, y)`.
- `right_click` — right-click by `element_index` (AXShowMenu) or `(x, y)`.
- `drag` — press-drag-release between two pixel endpoints (marquee, drag-and-drop, slider scrub, resize handles).
- `move_cursor` — warp the real cursor to `(x, y)`.

**Keyboard**
Expand Down
20 changes: 20 additions & 0 deletions docs/content/docs/cua-driver/reference/mcp-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,26 @@ Right-click by element (routes through `AXShowMenu`) or by pixel.
{"pid": 844, "window_id": 10725, "element_index": 7}
```

### drag

Press-drag-release gesture between two pixel endpoints. macOS AX has no semantic drag action, so this is pixel-only — there is no element-indexed mode. Use it for marquee/lasso selection, drag-and-drop, slider scrubbing, resize handles, panel repositioning. Frontmost target: posts via `.cghidEventTap` (real cursor traces the path; required for AppKit drag sources / canvas viewports). Backgrounded target: posts via the auth-signed pid-routed path (cursor-neutral).

**Arguments:**

- `pid` (integer, required): Target process ID.
- `from_x`, `from_y` (number, required): Drag-start in window-local screenshot pixels (top-left origin).
- `to_x`, `to_y` (number, required): Drag-end in the same space.
- `window_id` (integer, optional): CGWindowID the pixels were measured against. Defaults to the frontmost window of `pid`.
- `duration_ms` (integer, optional): Wall-clock budget for the path between mouseDown and mouseUp. Default: `500`.
- `steps` (integer, optional): Number of intermediate `mouseDragged` events linearly interpolated along the path. Default: `20`.
- `modifier` (array of string, optional): `cmd` / `shift` / `option` / `ctrl`. Held across the entire gesture (option-drag duplicates, shift-drag constrains the axis).
- `button` (string, optional): `left` / `right` / `middle`. Default: `left`.
- `from_zoom` (boolean, optional): When true, all four coordinates are in the last `zoom` image's pixel space.

```json
{"pid": 844, "window_id": 10725, "from_x": 100, "from_y": 200, "to_x": 400, "to_y": 200, "duration_ms": 600}
```

### move_cursor

Warp the real mouse cursor to a screen-point coordinate. Does not click.
Expand Down
1 change: 1 addition & 0 deletions libs/cua-driver/Skills/cua-driver/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ contract" above:
| Find a pid | `list_apps` or `launch_app`'s return | `pgrep`, `ps`, `osascript frontmost` |
| Enumerate an app's windows | `list_windows({pid})` — or read the `windows` array `launch_app` already returns | `osascript 'every window of app …'` |
| Click / type / scroll / keys | `click`, `type_text`, `scroll`, `press_key`, `hotkey` | `osascript`, `cliclick`, raw `CGEvent`, `open <url>` |
| Drag / drag-and-drop / marquee select | `drag({pid, from_x, from_y, to_x, to_y})` (pixel-only — macOS AX has no semantic drag) | `cliclick dd:`, `osascript drag` |
| Screenshot | `screenshot` or the PNG in `get_window_state` | `screencapture` |
| Quit an app | ask the user first, then `hotkey({pid, keys:["cmd","q"]})` | `kill`, `killall`, `pkill` |
| Hand a file/URL to an app | `launch_app({bundle_id, urls:[<path>]})` | `open -a <App> <path>`, `open <url>` |
Expand Down
203 changes: 203 additions & 0 deletions libs/cua-driver/Sources/CuaDriverCore/Input/MouseInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,209 @@ public enum MouseInput {
)
}

/// Synthesize a press-drag-release gesture from `start` to `end` in
/// screen points. Emits one `mouseDown` at `start`, `steps`
/// linearly-interpolated `mouseDragged` events along the path, and
/// one `mouseUp` at `end`. `durationMs` is the wall-clock budget
/// for the path between down and up; the time is split evenly
/// across the drag steps.
///
/// Frontmost target: posts via `.cghidEventTap` with a leading
/// `mouseMoved` so the recipient sees a real HID-origin gesture
/// (matches what AppKit drag sources, Finder selection, and
/// canvas-backed viewports expect). The user's real cursor
/// follows the drag path — unavoidable, since `cghidEventTap` is
/// the system input stream.
///
/// Backgrounded target: posts via `postBoth` (auth-signed
/// SkyLight + public `CGEvent.postToPid`). Cursor-neutral. Some
/// surfaces filter pid-routed mouseDragged events at the
/// event-source level (same OpenGL/GHOST-style filter that
/// affects pid-routed clicks); those targets need to be frontmost
/// for drags to land.
///
/// `modifiers` are held across every event in the gesture (down,
/// every dragged step, up), enabling option-drag (duplicate),
/// shift-drag (constrained axis), etc.
public static func drag(
from start: CGPoint,
to end: CGPoint,
toPid pid: pid_t,
button: Button = .left,
durationMs: Int = 500,
steps: Int = 20,
modifiers: [String] = []
) throws {
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)
Comment on lines +468 to +473

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).


let targetIsFrontmost =
NSRunningApplication(processIdentifier: pid)?.isActive ?? false
if targetIsFrontmost {
try dragFrontmostViaHIDTap(
from: start,
to: end,
button: button,
steps: clampedSteps,
perStepUs: perStepUs,
modifiers: modifiers
)
return
}

let (downType, upType) = nsEventTypes(for: button)
let draggedType = nsDraggedType(for: button)
let modifierFlags = modifierMask(for: modifiers)
let winNum = Int(
WindowEnumerator.frontmostWindow(forPid: pid)
.map { Int64(CGWindowID($0.id)) } ?? 0)

let down = try buildCGEvent(
type: downType,
location: cocoaLocation(fromScreenPoint: start),
modifierFlags: modifierFlags,
clickCount: 1,
button: button,
windowNumber: winNum
)
down.setIntegerValueField(.mouseEventClickState, value: 1)
postBoth(down, toPid: pid)

for step in 1...clampedSteps {
let progress = Double(step) / Double(clampedSteps)
let point = CGPoint(
x: start.x + (end.x - start.x) * progress,
y: start.y + (end.y - start.y) * progress
)
let drag = try buildCGEvent(
type: draggedType,
location: cocoaLocation(fromScreenPoint: point),
modifierFlags: modifierFlags,
clickCount: 1,
button: button,
windowNumber: winNum
)
drag.setIntegerValueField(.mouseEventClickState, value: 1)
usleep(perStepUs)
postBoth(drag, toPid: pid)
}

let up = try buildCGEvent(
type: upType,
location: cocoaLocation(fromScreenPoint: end),
modifierFlags: modifierFlags,
clickCount: 1,
button: button,
windowNumber: winNum
)
up.setIntegerValueField(.mouseEventClickState, value: 1)
usleep(perStepUs)
postBoth(up, toPid: pid)
}

/// Frontmost-target drag: route through `.cghidEventTap` so the
/// gesture originates from the system input stream — matches what
/// AppKit drag sources / Finder selection rect / canvas viewports
/// expect. The real cursor visibly traces the drag path; we
/// accept that for frontmost gestures.
private static func dragFrontmostViaHIDTap(
from start: CGPoint,
to end: CGPoint,
button: Button,
steps: Int,
perStepUs: useconds_t,
modifiers: [String]
) throws {
let (downType, upType) = cgEventTypes(for: button)
let draggedType = cgDraggedType(for: button)
let mouseButton: CGMouseButton = {
switch button {
case .left: return .left
case .right: return .right
case .middle: return .center
}
}()
let modifierFlags = cgEventFlags(for: modifiers)
let src = CGEventSource(stateID: .hidSystemState)

guard
let move = CGEvent(
mouseEventSource: src,
mouseType: .mouseMoved,
mouseCursorPosition: start,
mouseButton: mouseButton
)
else { throw MouseInputError.eventCreationFailed("drag hid-tap move") }
move.flags = modifierFlags
move.post(tap: .cghidEventTap)
usleep(30_000)

guard
let down = CGEvent(
mouseEventSource: src,
mouseType: downType,
mouseCursorPosition: start,
mouseButton: mouseButton
)
else { throw MouseInputError.eventCreationFailed("drag hid-tap down") }
down.flags = modifierFlags
down.setIntegerValueField(.mouseEventClickState, value: 1)
down.post(tap: .cghidEventTap)

for step in 1...steps {
let progress = Double(step) / Double(steps)
let point = CGPoint(
x: start.x + (end.x - start.x) * progress,
y: start.y + (end.y - start.y) * progress
)
guard
let drag = CGEvent(
mouseEventSource: src,
mouseType: draggedType,
mouseCursorPosition: point,
mouseButton: mouseButton
)
else { throw MouseInputError.eventCreationFailed("drag hid-tap step") }
drag.flags = modifierFlags
drag.setIntegerValueField(.mouseEventClickState, value: 1)
usleep(perStepUs)
drag.post(tap: .cghidEventTap)
}

guard
let up = CGEvent(
mouseEventSource: src,
mouseType: upType,
mouseCursorPosition: end,
mouseButton: mouseButton
)
else { throw MouseInputError.eventCreationFailed("drag hid-tap up") }
up.flags = modifierFlags
up.setIntegerValueField(.mouseEventClickState, value: 1)
usleep(perStepUs)
up.post(tap: .cghidEventTap)
}

private static func nsDraggedType(for button: Button) -> NSEvent.EventType {
switch button {
case .left: return .leftMouseDragged
case .right: return .rightMouseDragged
case .middle: return .otherMouseDragged
}
}

private static func cgDraggedType(for button: Button) -> CGEventType {
switch button {
case .left: return .leftMouseDragged
case .right: return .rightMouseDragged
case .middle: return .otherMouseDragged
}
}

// MARK: - Private helpers

private static func buildCGEvent(
Expand Down
2 changes: 2 additions & 0 deletions libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public struct ToolRegistry: Sendable {
public static let actionToolNames: Set<String> = [
"click",
"right_click",
"drag",
"scroll",
"type_text",
"type_text_chars",
Expand Down Expand Up @@ -230,6 +231,7 @@ public struct ToolRegistry: Sendable {
ClickTool.handler,
DoubleClickTool.handler,
RightClickTool.handler,
DragTool.handler,
SetValueTool.handler,
SetAgentCursorEnabledTool.handler,
SetAgentCursorMotionTool.handler,
Expand Down
Loading
Loading