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
135 changes: 72 additions & 63 deletions libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,18 @@ public final class AgentCursor {
/// the overlay is hidden or the cursor disabled.
private var activationObserver: NSObjectProtocol?

/// Short-lived task that re-runs `pinAbove` a few times after
/// each click to catch the async window-level raise that the
/// target does without the app becoming frontmost (so the
/// activation observer misses it). Cancelled and respawned on
/// every `pinAbove` call.
private var defensiveRepinTask: Task<Void, Never>?
/// Continuous repin loop: fires at ~30 fps while the overlay is
/// z-pinned to a target. Catches async window-level raises that
/// macOS issues after AX actions (the target can briefly elevate
/// above the overlay before any one-shot tick fires). Replaces
/// the previous sparse [60, 180, 360 …] defensive-repin schedule
/// — at 33ms the overlay snaps back within one frame rather than
/// up to 300ms, making the "dip behind" artefact imperceptible.
///
/// `CGWindowListCopyWindowInfo` at 30 fps costs a few hundred
/// microseconds per call — well within the 33ms budget and much
/// cheaper than pixel capture.
private var continuousRepinTask: Task<Void, Never>?

/// Count of consecutive `reapplyPinAbove` ticks that couldn't
/// find the pinned pid's on-screen window. Used to tolerate
Expand Down Expand Up @@ -294,14 +300,11 @@ public final class AgentCursor {
}

/// Pin the overlay just above the given pid's frontmost on-screen
/// window. Keeps the overlay at `.floating` (the init default)
/// and orders it above the target window so consecutive clicks on
/// the same target skip redundant re-orders. See
/// `reapplyPinAbove()` for the rationale on staying at `.floating`
/// instead of demoting to `.normal` — short version: `.normal`
/// lets Electron / Chromium targets transiently push themselves
/// above the cursor mid-click, making the overlay read as "blinks
/// out for a frame" on every click.
/// window at `.normal` level. `order(.above, relativeTo:)` places
/// the overlay exactly one slot above the target so any windows
/// that were already above the target remain above the overlay —
/// producing `[target, overlay, fg-windows]` z-ordering. Consecutive
/// clicks on the same target skip redundant re-orders.
///
/// When the target has no on-screen window (hidden launch still
/// pending, offscreen window, etc.) the overlay is ordered out
Expand All @@ -313,31 +316,27 @@ public final class AgentCursor {
missedPinCount = 0 // fresh pin — any earlier miss streak is stale
ensureActivationObserver()
reapplyPinAbove()
scheduleDefensiveRepin()
startContinuousRepin()
}

/// Re-run `pinAbove` a few times over the next ~1200ms to catch
/// the async window-level raise macOS sometimes does a few
/// frames after an AX click — when the target's *window* rises
/// in z-order but the *app* doesn't become frontmost, so
/// `didActivateApplicationNotification` never fires. Each call
/// is idempotent when the overlay is already correctly pinned;
/// cheap enough to run on a short schedule.
/// Start a continuous ~30 fps repin loop for the current target.
/// Fires every 33ms while `pinnedPid` is set, re-ordering the
/// overlay just above the target each tick. This catches window-
/// level raises that macOS issues asynchronously after AX actions
/// (without the activation notification firing) — the overlay
/// snaps back within ≤33ms instead of waiting up to 300ms for a
/// sparse defensive tick.
///
/// Coverage must span the full click lifecycle — `playClickPress`
/// runs for 650ms and `finishClick`'s dwell adds another
/// ~250ms. An earlier 700ms schedule let late-arriving target
/// raises (Electron / redraw-heavy apps in particular) land
/// after the last tick, stranding the overlay under the target
/// for the rest of the ripple. The current schedule tails out
/// to 1200ms with buffer, so ticks keep firing through the
/// ripple + dwell even for slower targets.
private func scheduleDefensiveRepin() {
defensiveRepinTask?.cancel()
defensiveRepinTask = Task { @MainActor [weak self] in
for delayMs in [60, 180, 360, 600, 900, 1200] {
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
guard let self, !Task.isCancelled else { return }
/// The loop exits automatically when `pinnedPid` is cleared
/// (overlay hidden or target gone) so there's no separate
/// cancellation needed in the common path. Explicit cancellation
/// is still done in `hide()` for immediate tear-down.
private func startContinuousRepin() {
continuousRepinTask?.cancel()
continuousRepinTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 33_000_000) // ~30 fps
guard let self, !Task.isCancelled, self.pinnedPid != nil else { return }
self.reapplyPinAbove()
}
}
Expand Down Expand Up @@ -378,24 +377,20 @@ public final class AgentCursor {
return
}
missedPinCount = 0
// Keep the overlay at its initial `.floating` level — above
// ordinary `.normal` app windows without competing for z-order
// against them. Previously this demoted the overlay to
// `.normal` and re-ordered it just above the target so
// unrelated apps stacked over the target would occlude the
// cursor (aesthetic nicety). But at `.normal`, any redraw /
// re-raise on the target — especially for Electron / Chromium
// apps that re-stack their own window on AX-dispatched clicks
// — can transiently push the target above the overlay. Ripple
// animation then plays behind the app and the cursor reads
// as "disappears for a second" on every click. At `.floating`,
// the window server guarantees ordering against `.normal`, so
// the cursor stays visible through the click regardless of
// what the target does to its own z-stack.
// Place the overlay just above the target within the `.normal`
// window level — producing the ordering:
// [target-window, overlay, windows-that-were-above-target]
// This means windows that were already in front of the target
// (e.g. the user's foreground app) correctly occlude the cursor,
// making it clear which window the agent is interacting with.
//
// `order(.above, relativeTo:)` is still worth running: it
// keeps the overlay above other `.floating` windows (ours or
// the system's) that might otherwise appear over it.
// The overlay was previously kept at `.floating` so the window
// server guaranteed it stayed above all `.normal` windows. The
// downside is that it covered the user's foreground app, making
// the z-ordering visually misleading for background-window
// automation. Now at `.normal`, apps above the target remain
// above the overlay — background interaction stays visually
// sandwiched where it belongs.
win.order(.above, relativeTo: targetWindow.id)
pinnedWindowId = targetWindow.id
}
Expand Down Expand Up @@ -432,26 +427,26 @@ public final class AgentCursor {
NSWorkspace.shared.notificationCenter.removeObserver(obs)
activationObserver = nil
}
defensiveRepinTask?.cancel()
defensiveRepinTask = nil
continuousRepinTask?.cancel()
continuousRepinTask = nil
}

/// Hide the overlay window. No-op if not shown. Keeps the window
/// retained so the next `show()` is instant.
///
/// Also clears the pin state and cancels the defensive repin
/// task. Without this cleanup, subsequent
/// `didActivateApplicationNotification` events (or any still-
/// queued defensive repin ticks) would call `reapplyPinAbove`,
/// which re-orders the overlay window into the z-stack — visibly
/// resurrecting the cursor the idle-hide timer just removed.
/// Also clears the pin state and stops the continuous repin loop.
/// Without this cleanup, the loop would keep calling
/// `reapplyPinAbove`, re-ordering the overlay window back into
/// the z-stack — visibly resurrecting the cursor the idle-hide
/// timer just removed.
public func hide() {
overlay?.orderOut(nil)
pinnedWindowId = nil
pinnedPid = nil
missedPinCount = 0
defensiveRepinTask?.cancel()
defensiveRepinTask = nil
continuousRepinTask?.cancel()
continuousRepinTask = nil
clearFocusRect()
}
Comment thread
ddupont808 marked this conversation as resolved.

/// Move the cursor immediately to a screen-point coordinate. No
Expand Down Expand Up @@ -545,6 +540,20 @@ public final class AgentCursor {
scheduleIdleHide()
}

/// Show a glowing highlight rectangle around the given screen rect.
/// Used by ClickTool to draw a focus indicator on the targeted AX
/// element. Pass nil to clear the rect. No-op when disabled.
public func showFocusRect(_ rect: CGRect?) {
guard isEnabled else { return }
AgentCursorRenderer.shared.focusRect = rect
}

/// Clear the glowing focus rect. Called by hide() so the rect
/// doesn't linger after the cursor auto-hides.
private func clearFocusRect() {
AgentCursorRenderer.shared.focusRect = nil
}

/// For tests + spike code: tear down the window so the next
/// `show()` rebuilds it from scratch. Not part of the public
/// tool-surface contract.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ public final class AgentCursorOverlayWindow: NSWindow {
backgroundColor = .clear
hasShadow = false
ignoresMouseEvents = true
// Start at `.floating` — above ordinary app windows, below
// menus and modals. `AgentCursor.pinAbove(pid:)` re-parents
// the overlay into `.normal` and z-orders it just above the
// target's frontmost window on every click, so unrelated apps
// layered over the target correctly occlude the cursor. The
// `.floating` default only matters for the initial show
// before any pointer action has run.
level = .floating
// `.normal` level so the overlay is sandwiched in the regular
// window stack. `AgentCursor.pinAbove(pid:)` calls
// `order(.above, relativeTo: targetWindowId)` to place the
// overlay exactly one slot above the target window — windows
// that were already above the target remain above the overlay.
// This produces the ordering: [target, overlay, fg-windows].
level = .normal
collectionBehavior = [
.canJoinAllSpaces, .fullScreenAuxiliary, .stationary,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public final class AgentCursorRenderer {
private(set) public var position: CGPoint = .init(x: -200, y: -200)
/// Visual heading in radians (tip points along this vector).
private(set) public var heading: Double = .pi / 4 // ~NW, like the OS cursor
/// Screen-space bounding rect of the most recently targeted AX element.
/// Drawn as a glowing highlight rectangle in the overlay view.
/// Nil when no element is targeted or the cursor is hidden.
public var focusRect: CGRect? = nil

// -------- Internal state ---------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,63 @@ public struct AgentCursorView: View {

public var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 120.0)) { ctx in
Canvas { gctx, _ in
Canvas { gctx, size in
renderer.tick(now: ctx.date.timeIntervalSinceReferenceDate)
drawFocusRect(in: gctx, canvasSize: size)
drawCursor(in: gctx)
}
.ignoresSafeArea()
.allowsHitTesting(false)
}
}

/// Draw a glowing highlight rectangle around the currently targeted AX
/// element (if `renderer.focusRect` is set). The rect is in screen-point
/// coordinates; the overlay window's frame is the full screen, so we
/// convert from screen-point to canvas-local by subtracting the screen's
/// origin. Uses a cyan-glow border with a faint fill to mark the target.
private func drawFocusRect(in ctx: GraphicsContext, canvasSize: CGSize) {
guard let screenRect = renderer.focusRect else { return }
// The overlay window covers the whole screen, and the canvas's
// coordinate origin is the top-left of the screen. Screen-point
// coordinates (CoreGraphics, top-left origin on macOS) map
// directly to canvas coordinates — no offset needed.
let r = CGRect(
x: screenRect.minX,
y: screenRect.minY,
width: screenRect.width,
height: screenRect.height
)
let cornerRadius: CGFloat = 4
let rounded = Path(roundedRect: r, cornerRadius: cornerRadius)

// Faint fill — shows the full extent of the target.
ctx.fill(
rounded,
with: .color(
Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.08)
)
)

// Solid bright border.
ctx.stroke(
rounded,
with: .color(
Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.90)
),
lineWidth: 2
)

// Wide soft glow stroke for the neon halo effect.
ctx.stroke(
rounded,
with: .color(
Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.30)
),
lineWidth: 8
)
}

/// Draw the cursor arrow centered on `renderer.position`, rotated to
/// `renderer.heading`. The shape is a 4-vertex pointer arrow with
/// the tip along +x before rotation, which the caller rotates by
Expand Down
24 changes: 24 additions & 0 deletions libs/cua-driver/Sources/CuaDriverCore/Input/AXInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@ public enum AXInput {
)
}

/// The element's screen-point bounding rect (top-left origin).
/// Returns nil when the element has no position / size.
/// Public entry point used by ClickTool for the focus-rect overlay.
public static func screenBoundingRect(of element: AXUIElement) -> CGRect? {
return boundingRect(of: element)
}

/// Read the element's `AXChildren` attribute. Returns an empty array on
/// any error (element has no children, AX permission denied, etc.).
public static func children(of element: AXUIElement) -> [AXUIElement] {
var ref: CFTypeRef?
guard
AXUIElementCopyAttributeValue(element, "AXChildren" as CFString, &ref) == .success,
let arr = ref as? [AXUIElement]
else { return [] }
return arr
}

/// Read a string attribute from an element. Returns nil when the attribute
/// is absent, unreadable, or not a string.
public static func stringAttribute(_ name: String, of element: AXUIElement) -> String? {
return attributeString(element, name)
}

/// The element's on-screen center in screen-point coordinates
/// (top-left origin). Returns nil when the element has no
/// position / size (menus, offscreen elements, hidden rows).
Expand Down
54 changes: 53 additions & 1 deletion libs/cua-driver/Sources/CuaDriverServer/Tools/ClickTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@ public enum ClickTool {
) {
try AXInput.performAction(axAction, on: element)
}
// For text fields (AXTextField / AXTextArea), WebKit establishes
// DOM focus asynchronously after AXPress returns. Without a pause,
// a follow-up type_text_chars call races with WebKit's focus setup
// and sends chars before the input is active — chars are silently
// dropped. This is especially pronounced for email/number inputs
// when the app is backgrounded (Safari is non-frontmost).
//
// Empirically, 800 ms is reliably sufficient: a direct Python
// integration test showed immediate click+type fails but click +
// 2 s + type succeeds; 800 ms gives comfortable margin while
// keeping the UX fast enough for interactive use.
let target = AXInput.describe(element)
if axAction == "AXPress",
let role = target.role,
role == "AXTextField" || role == "AXTextArea"
{
try? await Task.sleep(for: .milliseconds(800))
}
// AX-dispatched clicks can raise the target window to
// the top of its level, leaving the overlay stranded
// beneath it. Re-pin BEFORE the press-in / dwell so
Expand All @@ -279,6 +297,14 @@ public enum ClickTool {
await MainActor.run {
AgentCursor.shared.pinAbove(pid: pid)
}
// If the element has a bounding rect, show a glowing focus
// highlight on the cursor overlay so the user can see which
// element the agent is targeting.
if let rect = AXInput.screenBoundingRect(of: element) {
await MainActor.run {
AgentCursor.shared.showFocusRect(rect)
}
}
// Press-in / release pulse on the cursor — purely
// visual confirmation that the click fired.
// No-op when disabled.
Expand All @@ -287,9 +313,35 @@ public enum ClickTool {
// period and arm the idle-hide timer. No-op when
// disabled.
await AgentCursor.shared.finishClick(pid: pid)
let target = AXInput.describe(element)
var summary =
"✅ Performed \(axAction) on [\(index)] \(target.role ?? "?") \"\(target.title ?? "")\"."
// For popup buttons (HTML <select> elements in Safari/WebKit):
// the native macOS popup menu that AXPress opens immediately
// closes when the app is non-frontmost, so the visible options
// are never selectable from the keyboard. Instead, list the
// available options from the AX children and direct the caller
// to use set_value — which AX-presses the specific child option
// directly, bypassing the native menu entirely.
if target.role == "AXPopUpButton" {
let children = AXInput.children(of: element)
let options: [(title: String, value: String)] = children.compactMap { child in
let t = AXInput.stringAttribute("AXTitle", of: child) ?? ""
let v = AXInput.stringAttribute("AXValue", of: child) ?? ""
guard !t.isEmpty || !v.isEmpty else { return nil }
return (title: t, value: v)
}
if !options.isEmpty {
let optList = options.map { o in
o.value.isEmpty || o.value == o.title ? "\"\(o.title)\"" : "\"\(o.title)\" (value: \(o.value))"
}.joined(separator: ", ")
summary += "\n\n⚠️ This is a popup/select button. The native macOS menu"
summary += " closes immediately when the window is in the background."
summary += " Do NOT use click again — instead, use:"
summary += "\n set_value(pid: \(pid), window_id: \(windowId), element_index: \(index),"
summary += " value: \"<option title>\")"
summary += "\nAvailable options: [\(optList)]"
}
}
// If the element didn't advertise the action we just
// dispatched, append a non-fatal warning. The AX call
// returned `.success` but the element almost certainly
Expand Down
Loading
Loading