diff --git a/docs/content/docs/cua-driver/guide/getting-started/swift-integration.mdx b/docs/content/docs/cua-driver/guide/getting-started/swift-integration.mdx index 36019a9a18..e5e981ddfe 100644 --- a/docs/content/docs/cua-driver/guide/getting-started/swift-integration.mdx +++ b/docs/content/docs/cua-driver/guide/getting-started/swift-integration.mdx @@ -96,3 +96,56 @@ Calling AX or screen-capture APIs from an embedded library still requires the ho - **Screen Recording** — for `WindowCapture` screenshots. Grant these under **System Settings → Privacy & Security** against your app's bundle identifier, the same as for the standalone `CuaDriver.app`. + +## Cursor customization + +`AgentCursorRenderer.shared` drives the visual style applied to every click animation. Set it at app startup from the `@MainActor` context — changes take effect on the next rendered frame. + +### Custom gradient colors + +```swift +import CuaDriverCore + +await MainActor.run { + AgentCursorRenderer.shared.style = AgentCursorStyle( + strokeGradientStops: [ + AgentCursorGradientStop(color: NSColor(hex: "#A855F7")!, location: 0), + AgentCursorGradientStop(color: NSColor(hex: "#6366F1")!, location: 1), + ], + bloomColor: NSColor(hex: "#A855F7")! + ) +} +``` + +### Custom PNG / SVG cursor image + +Replace the default arrow with any image `NSImage` can load (PNG, JPEG, PDF, SVG on macOS 12+): + +```swift +import CuaDriverCore + +await MainActor.run { + let img = NSImage(contentsOf: Bundle.main.url(forResource: "cursor", withExtension: "png")!) + AgentCursorRenderer.shared.style = AgentCursorStyle(image: img) +} +``` + +The image is drawn at `shapeSize × shapeSize` points (default 22 pt), centered on the cursor position and rotated to track the motion heading. To suppress the bloom halo behind it, pass `bloomCenterAlpha: 0`. + +### Revert to default + +```swift +await MainActor.run { + AgentCursorRenderer.shared.style = .default +} +``` + +### All style knobs + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `image` | `NSImage?` | `nil` | Custom cursor image (replaces arrow when non-nil) | +| `strokeGradientStops` | `[AgentCursorGradientStop]` | ice-blue → cyan → mint | Arrow fill gradient | +| `bloomColor` | `NSColor` | cyan `#5EC0E8` | Bloom halo and focus-rect color | +| `bloomCenterAlpha` | `CGFloat` | `0.55` | Center opacity of the bloom glow | +| `shapeSize` | `CGFloat` | `22` | Width and height of the cursor shape (points) | diff --git a/docs/content/docs/cua-driver/reference/mcp-tools.mdx b/docs/content/docs/cua-driver/reference/mcp-tools.mdx index ccec23346e..0b19c1f3b7 100644 --- a/docs/content/docs/cua-driver/reference/mcp-tools.mdx +++ b/docs/content/docs/cua-driver/reference/mcp-tools.mdx @@ -388,6 +388,22 @@ Toggle the overlay. Persists to config. Tune the Bezier-arc + spring motion knobs. All fields are optional; omitted fields keep their current value. +### set_agent_cursor_style + +Customize the cursor's visual appearance. All fields optional; omitted fields keep their current value. Changes persist to config across restarts. + +**Arguments:** + +- `gradient_colors` (array of string, optional): CSS hex color strings (`#RRGGBB` or `#RGB`) defining the arrow fill gradient from tip to tail. Pass an empty array `[]` to revert to the default. +- `bloom_color` (string, optional): CSS hex color for the bloom halo and the focus-rect highlight around clicked elements. Pass `""` to revert. +- `image_path` (string, optional): Absolute or `~`-rooted path to a PNG, JPEG, PDF, or SVG file. When set, replaces the default procedural arrow. Pass `""` to revert to the arrow. + +```json +{"gradient_colors": ["#A855F7", "#6366F1"], "bloom_color": "#A855F7"} +{"image_path": "~/cursors/my-cursor.png"} +{"gradient_colors": [], "bloom_color": "", "image_path": ""} +``` + **Arguments:** - `start_handle` (number, optional): Start-handle fraction in [0, 1]. Default 0.3. diff --git a/libs/cua-driver/Sources/CuaDriverCore/Config/CuaDriverConfig.swift b/libs/cua-driver/Sources/CuaDriverCore/Config/CuaDriverConfig.swift index 963f123905..f104a61566 100644 --- a/libs/cua-driver/Sources/CuaDriverCore/Config/CuaDriverConfig.swift +++ b/libs/cua-driver/Sources/CuaDriverCore/Config/CuaDriverConfig.swift @@ -191,6 +191,10 @@ public struct AgentCursorConfig: Codable, Sendable, Equatable { /// five sibling fields fighting for space with `enabled`. public var motion: Motion + /// Persisted cursor style overrides. Applied to `AgentCursorRenderer.shared` + /// at daemon startup via `AgentCursor.shared.apply(config:)`. + public var style: Style + public struct Motion: Codable, Sendable, Equatable { /// Start-handle fraction along the straight line, in `[0, 1]`. public var startHandle: Double @@ -220,12 +224,55 @@ public struct AgentCursorConfig: Codable, Sendable, Equatable { public static let `default` = Motion() } + /// Persisted visual style overrides. Only non-nil fields are applied; + /// nil fields fall back to `AgentCursorStyle.default`. + public struct Style: Codable, Sendable, Equatable { + /// Gradient color stops as CSS hex strings (#RRGGBB / #RGB). + /// When set, replaces the default ice-blue→cyan→mint gradient. + public var gradientColors: [String]? + + /// Bloom halo color as a CSS hex string. When set, also tints the + /// focus-rect highlight drawn around clicked AX elements. + public var bloomColor: String? + + /// Absolute or `~`-rooted path to a PNG, JPEG, PDF, or SVG file + /// that replaces the default procedural arrow shape. + public var imagePath: String? + + public init( + gradientColors: [String]? = nil, + bloomColor: String? = nil, + imagePath: String? = nil + ) { + self.gradientColors = gradientColors + self.bloomColor = bloomColor + self.imagePath = imagePath + } + + public static let `default` = Style() + } + + private enum CodingKeys: String, CodingKey { + case enabled + case motion + case style + } + public init( enabled: Bool = true, - motion: Motion = .default + motion: Motion = .default, + style: Style = .default ) { self.enabled = enabled self.motion = motion + self.style = style + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.enabled = (try? container.decode(Bool.self, forKey: .enabled)) ?? true + self.motion = (try? container.decode(Motion.self, forKey: .motion)) ?? .default + self.style = (try? container.decode(Style.self, forKey: .style)) ?? .default } public static let `default` = AgentCursorConfig() diff --git a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursor.swift b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursor.swift index 937ee984cd..c73ac7ecf8 100644 --- a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursor.swift +++ b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursor.swift @@ -5,7 +5,7 @@ import SwiftUI /// Named color stop for the agent-cursor's axial stroke gradient. /// Pairing color + location keeps the spec's lavender stops grep-able /// in one place — touch these values to retint the pointer. -public struct AgentCursorGradientStop: Sendable { +public struct AgentCursorGradientStop: @unchecked Sendable { public let color: NSColor public let location: CGFloat public init(color: NSColor, location: CGFloat) { @@ -14,53 +14,72 @@ public struct AgentCursorGradientStop: Sendable { } } -/// Visual constants for the agent cursor — shape sizing, gradient -/// stops, bloom falloff, stroke widths. Hard-coded and grep-able per -/// the design spec (see `docs/_local/agent-cursor-redesign.md`). Not -/// exposed via `set_agent_cursor_motion`; a separate -/// `set_agent_cursor_style` tool can land later if per-session theming -/// becomes a real ask. -public struct AgentCursorStyle: Sendable { - /// Container layer size. Grew 25pt → 60pt to hold the bloom - /// without clipping. Window frame is unchanged — the container is - /// visual only, no hit-test implication. +/// Visual constants for the agent cursor — shape sizing, gradient stops, +/// bloom falloff, stroke widths, and an optional custom image. +/// +/// ## Custom cursor image +/// +/// Set `image` to an `NSImage` (PNG, JPEG, PDF, or SVG loaded via +/// `NSImage(contentsOf:)`) to replace the default procedural arrow with +/// your own graphic. The image is drawn at `shapeSize × shapeSize` points, +/// rotated to track the motion heading. The bloom halo is still rendered +/// underneath — set `bloomCenterAlpha: 0` to suppress it. +/// +/// ## Custom colors +/// +/// Override `strokeGradientStops` to change the arrow fill, and +/// `bloomColor` / `bloomCenterAlpha` to change the glow hue. Parse hex +/// strings with `NSColor(hex:)`. +/// +/// ## `@unchecked Sendable` +/// +/// NSColor and NSImage are reference types without formal Sendable +/// conformance in Swift 6. All fields are `let` (immutable after init), +/// so concurrent reads are safe — hence `@unchecked`. +public struct AgentCursorStyle: @unchecked Sendable { + /// Container layer size (points). Default 60. public let containerSize: CGFloat - /// Rounded-arrow shape size (tip-to-base). Unchanged from the - /// previous triangle: 15pt reads as a cursor tip without feeling - /// chunky. + /// Drawn size of the cursor shape (points). Default 22. public let shapeSize: CGFloat - /// Axial stroke gradient — lavender family. 135° rotation (set via - /// `strokeGradientAngleDegrees`) puts the near-white stop at the - /// top-left (tip) and the indigo stop at the bottom-right (tail). + /// Axial stroke gradient stops for the procedural arrow. Ignored when + /// `image` is set. public let strokeGradientStops: [AgentCursorGradientStop] public let strokeGradientAngleDegrees: CGFloat public let strokeWidth: CGFloat public let highlightStrokeWidth: CGFloat - /// Lavender bloom — single radial `CAGradientLayer` below the - /// stroke. The hex stays constant; the envelope is an opacity - /// curve. `bloomCenterAlpha` is the resting center alpha; - /// `bloomBreathPeak` is the max the glide animation breathes up - /// to. `bloomMidAlpha` is the alpha at the 50% color-stop. + /// Bloom halo color. Also used for the focus-rect highlight. public let bloomColor: NSColor public let bloomCenterAlpha: CGFloat public let bloomMidAlpha: CGFloat public let bloomBreathPeak: CGFloat + /// Optional custom cursor image. When non-nil, the procedural arrow + /// is replaced by this image rendered at `shapeSize × shapeSize` + /// points, rotated to track the motion heading. Accepts any format + /// NSImage can load: PNG, JPEG, PDF, SVG (macOS 12+). + /// + /// Load from a file path: + /// ```swift + /// AgentCursorStyle(image: NSImage(contentsOf: URL(fileURLWithPath: "/path/to/cursor.png"))) + /// ``` + public let image: NSImage? + public init( - containerSize: CGFloat, - shapeSize: CGFloat, - strokeGradientStops: [AgentCursorGradientStop], - strokeGradientAngleDegrees: CGFloat, - strokeWidth: CGFloat, - highlightStrokeWidth: CGFloat, - bloomColor: NSColor, - bloomCenterAlpha: CGFloat, - bloomMidAlpha: CGFloat, - bloomBreathPeak: CGFloat + containerSize: CGFloat = 60, + shapeSize: CGFloat = 22, + strokeGradientStops: [AgentCursorGradientStop] = AgentCursorStyle.defaultGradientStops, + strokeGradientAngleDegrees: CGFloat = 135, + strokeWidth: CGFloat = 2, + highlightStrokeWidth: CGFloat = 0.5, + bloomColor: NSColor = NSColor(red: 0x5E / 255, green: 0xC0 / 255, blue: 0xE8 / 255, alpha: 1), + bloomCenterAlpha: CGFloat = 0.55, + bloomMidAlpha: CGFloat = 0.15, + bloomBreathPeak: CGFloat = 0.75, + image: NSImage? = nil ) { self.containerSize = containerSize self.shapeSize = shapeSize @@ -72,49 +91,60 @@ public struct AgentCursorStyle: Sendable { self.bloomCenterAlpha = bloomCenterAlpha self.bloomMidAlpha = bloomMidAlpha self.bloomBreathPeak = bloomBreathPeak + self.image = image } - /// The single locked-in style. Values per the design spec. Edit - /// these to retint / resize the pointer — no other call sites - /// should be reading them directly. - public static let `default` = AgentCursorStyle( - containerSize: 60, - // SVG content fills ~18/24 of the shape frame (has built-in - // padding), so the effective drawn size is 75% of `shapeSize`. - // 22pt gives a visible cursor around 16-17pt — comparable to the - // macOS system cursor. - shapeSize: 22, - // cua-driver heritage gradient: ice-blue tip → cyan body → mint - // tail. Axial 135° (from upper-left to lower-right) traces the - // cursor's own tip-to-tail axis, so the tip reads brightest. - strokeGradientStops: [ - AgentCursorGradientStop( - color: NSColor(red: 0xDB / 255, green: 0xEE / 255, blue: 0xFF / 255, alpha: 1), - location: 0.0 - ), - AgentCursorGradientStop( - color: NSColor(red: 0x5E / 255, green: 0xC0 / 255, blue: 0xE8 / 255, alpha: 1), - location: 0.53 - ), - AgentCursorGradientStop( - color: NSColor(red: 0x54 / 255, green: 0xCD / 255, blue: 0xA0 / 255, alpha: 1), - location: 1.0 - ), - ], - strokeGradientAngleDegrees: 135, - // 2pt white outline wraps the gradient-filled shape. Highlight - // stroke field is retained in the style struct for back-compat - // but not used by the current renderer (the layer tree has - // gradient-fill + white-border, no separate highlight stroke). - strokeWidth: 2, - highlightStrokeWidth: 0.5, - // Bloom matches the gradient's mid stop (cyan) so the halo reads - // as the cursor "exhaling color" rather than an unrelated hue. - bloomColor: NSColor(red: 0x5E / 255, green: 0xC0 / 255, blue: 0xE8 / 255, alpha: 1), - bloomCenterAlpha: 0.55, - bloomMidAlpha: 0.15, - bloomBreathPeak: 0.75 - ) + // cua-driver heritage gradient: ice-blue tip → cyan body → mint tail. + public static let defaultGradientStops: [AgentCursorGradientStop] = [ + AgentCursorGradientStop( + color: NSColor(red: 0xDB / 255, green: 0xEE / 255, blue: 0xFF / 255, alpha: 1), + location: 0.0 + ), + AgentCursorGradientStop( + color: NSColor(red: 0x5E / 255, green: 0xC0 / 255, blue: 0xE8 / 255, alpha: 1), + location: 0.53 + ), + AgentCursorGradientStop( + color: NSColor(red: 0x54 / 255, green: 0xCD / 255, blue: 0xA0 / 255, alpha: 1), + location: 1.0 + ), + ] + + public static let `default` = AgentCursorStyle() +} + +// MARK: - NSColor hex parsing + +extension NSColor { + /// Parse a CSS hex color string: `#RGB`, `#RRGGBB`, or `#RRGGBBAA`. + /// Returns nil when the string is not a valid hex color. + public convenience init?(hex: String) { + var str = hex.trimmingCharacters(in: .whitespaces) + if str.hasPrefix("#") { str = String(str.dropFirst()) } + if str.count == 3 { + str = str.map { "\($0)\($0)" }.joined() + } + var value: UInt64 = 0 + guard Scanner(string: str).scanHexInt64(&value) else { return nil } + switch str.count { + case 6: + self.init( + red: CGFloat((value >> 16) & 0xFF) / 255, + green: CGFloat((value >> 8) & 0xFF) / 255, + blue: CGFloat(value & 0xFF) / 255, + alpha: 1 + ) + case 8: + self.init( + red: CGFloat((value >> 24) & 0xFF) / 255, + green: CGFloat((value >> 16) & 0xFF) / 255, + blue: CGFloat((value >> 8) & 0xFF) / 255, + alpha: CGFloat(value & 0xFF) / 255 + ) + default: + return nil + } + } } /// The agent cursor overlay — a purely visual floating arrow that @@ -273,6 +303,39 @@ public final class AgentCursor { arcFlow: CGFloat(config.motion.arcFlow), spring: CGFloat(config.motion.spring) ) + applyStyleConfig(config.style) + } + + /// Apply a custom visual style to the cursor overlay. Takes effect + /// immediately — the next rendered frame picks up the new style. + /// Swift dep users call this directly; MCP users go through + /// `set_agent_cursor_style`. + public func setStyle(_ style: AgentCursorStyle) { + AgentCursorRenderer.shared.style = style + } + + public func applyStyleConfig(_ styleConfig: AgentCursorConfig.Style) { + let gradientStops: [AgentCursorGradientStop] + if let hexColors = styleConfig.gradientColors, !hexColors.isEmpty { + gradientStops = hexColors.enumerated().compactMap { i, hex -> AgentCursorGradientStop? in + guard let color = NSColor(hex: hex) else { return nil } + let loc = CGFloat(i) / CGFloat(max(hexColors.count - 1, 1)) + return AgentCursorGradientStop(color: color, location: loc) + } + } else { + gradientStops = AgentCursorStyle.defaultGradientStops + } + let bloomColor = styleConfig.bloomColor.flatMap { NSColor(hex: $0) } + ?? NSColor(red: 0x5E / 255, green: 0xC0 / 255, blue: 0xE8 / 255, alpha: 1) + var nsImage: NSImage? = nil + if let path = styleConfig.imagePath { + nsImage = NSImage(contentsOf: URL(fileURLWithPath: (path as NSString).expandingTildeInPath)) + } + setStyle(AgentCursorStyle( + strokeGradientStops: gradientStops, + bloomColor: bloomColor, + image: nsImage + )) } /// Show the overlay window. Idempotent — successive calls are diff --git a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorRenderer.swift b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorRenderer.swift index 453bf6a50c..db24f285e7 100644 --- a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorRenderer.swift +++ b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorRenderer.swift @@ -56,6 +56,11 @@ public final class AgentCursorRenderer { /// Nil when no element is targeted or the cursor is hidden. public var focusRect: CGRect? = nil + /// Visual style applied to the cursor overlay. Changing this property + /// takes effect on the next rendered frame — `AgentCursorView` reads + /// it via `@Observable`. Set via `AgentCursor.shared.setStyle(_:)`. + public var style: AgentCursorStyle = .default + // -------- Internal state --------------------------------------------- private var path: PlannedPath? diff --git a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorView.swift b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorView.swift index e2dbf13e2d..b02d972c16 100644 --- a/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorView.swift +++ b/libs/cua-driver/Sources/CuaDriverCore/Cursor/AgentCursorView.swift @@ -28,16 +28,11 @@ public struct AgentCursorView: View { } /// 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. + /// element (if `renderer.focusRect` is set). Color is derived from the + /// current cursor style's bloom color so the focus rect always matches + /// the cursor's visual identity. 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, @@ -46,92 +41,77 @@ public struct AgentCursorView: View { ) let cornerRadius: CGFloat = 4 let rounded = Path(roundedRect: r, cornerRadius: cornerRadius) + let baseColor = Color(nsColor: renderer.style.bloomColor) - // 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 - ) + ctx.fill(rounded, with: .color(baseColor.opacity(0.08))) + ctx.stroke(rounded, with: .color(baseColor.opacity(0.90)), lineWidth: 2) + ctx.stroke(rounded, with: .color(baseColor.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 - /// `heading + π` (so the visible tip trails opposite the motion - /// vector — matching macOS cursor convention). + /// Draw the cursor centered on `renderer.position`, rotated to + /// `renderer.heading`. When `renderer.style.image` is set, draws that + /// image instead of the default gradient arrow. private func drawCursor(in ctx: GraphicsContext) { let p = renderer.position guard p.x > -100 else { return } // skip until first moveTo - // Arrow path — tip at (14, 0), tail extends to the left. - var shape = Path() - shape.move(to: CGPoint(x: 14, y: 0)) - shape.addLine(to: CGPoint(x: -8, y: -9)) - shape.addLine(to: CGPoint(x: -3, y: 0)) - shape.addLine(to: CGPoint(x: -8, y: 9)) - shape.closeSubpath() - - // `renderer.heading` is the visual heading = motion_direction + π. - // The arrow path has its tip at +x, so we rotate by heading + π - // to make the tip point in the motion direction (standard cursor - // convention where the pointer leads rather than trails). - let transform = CGAffineTransform(translationX: p.x, y: p.y) - .rotated(by: CGFloat(renderer.heading + .pi)) - - let transformed = shape.applying(transform) - - // Ice-blue gradient fill (matches existing agent-cursor palette). - ctx.fill( - transformed, - with: .linearGradient( - Gradient(colors: [ - Color(red: 0xDB/255, green: 0xEE/255, blue: 0xFF/255), - Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255), - Color(red: 0x54/255, green: 0xCD/255, blue: 0xA0/255), - ]), - startPoint: CGPoint(x: p.x + 14, y: p.y - 9), - endPoint: CGPoint(x: p.x - 8, y: p.y + 9) - ) - ) - // White outline for legibility on any background. - ctx.stroke(transformed, with: .color(.white), lineWidth: 1.5) - - // Cyan bloom halo — radial glow that reads as agent presence. + let style = renderer.style + let bloomColor = Color(nsColor: style.bloomColor) let bloomR: CGFloat = 22 let bloomRect = CGRect(x: p.x - bloomR, y: p.y - bloomR, width: bloomR * 2, height: bloomR * 2) + + // Bloom halo — drawn first (underneath) regardless of cursor mode. ctx.fill( Path(ellipseIn: bloomRect), with: .radialGradient( Gradient(colors: [ - Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.45), - Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.10), - Color(red: 0x5E/255, green: 0xC0/255, blue: 0xE8/255).opacity(0.0), + bloomColor.opacity(style.bloomCenterAlpha), + bloomColor.opacity(style.bloomMidAlpha), + bloomColor.opacity(0), ]), center: p, startRadius: 0, endRadius: bloomR ) ) + + if let nsImage = style.image { + // Custom image mode: draw the image centered on `p`, rotated + // to heading. GraphicsContext is a value type — copy before + // applying the transform so the bloom above is unaffected. + var imgCtx = ctx + imgCtx.translateBy(x: p.x, y: p.y) + imgCtx.rotate(by: Angle(radians: renderer.heading + .pi)) + let s = style.shapeSize + imgCtx.draw(Image(nsImage: nsImage), + in: CGRect(x: -s / 2, y: -s / 2, width: s, height: s)) + } else { + // Procedural arrow mode: 4-vertex pointer shape with gradient fill. + var shape = Path() + shape.move(to: CGPoint(x: 14, y: 0)) + shape.addLine(to: CGPoint(x: -8, y: -9)) + shape.addLine(to: CGPoint(x: -3, y: 0)) + shape.addLine(to: CGPoint(x: -8, y: 9)) + shape.closeSubpath() + + let transform = CGAffineTransform(translationX: p.x, y: p.y) + .rotated(by: CGFloat(renderer.heading + .pi)) + let transformed = shape.applying(transform) + + let gradientColors = style.strokeGradientStops.isEmpty + ? AgentCursorStyle.defaultGradientStops.map { Color(nsColor: $0.color) } + : style.strokeGradientStops.map { Color(nsColor: $0.color) } + + ctx.fill( + transformed, + with: .linearGradient( + Gradient(colors: gradientColors), + startPoint: CGPoint(x: p.x + 14, y: p.y - 9), + endPoint: CGPoint(x: p.x - 8, y: p.y + 9) + ) + ) + ctx.stroke(transformed, with: .color(.white), lineWidth: style.strokeWidth) + } } } diff --git a/libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift b/libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift index 2b6f1e9a28..c5ef3cd6ca 100644 --- a/libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift +++ b/libs/cua-driver/Sources/CuaDriverServer/ToolRegistry.swift @@ -235,6 +235,7 @@ public struct ToolRegistry: Sendable { SetValueTool.handler, SetAgentCursorEnabledTool.handler, SetAgentCursorMotionTool.handler, + SetAgentCursorStyleTool.handler, GetAgentCursorStateTool.handler, SetRecordingTool.handler, GetRecordingStateTool.handler, diff --git a/libs/cua-driver/Sources/CuaDriverServer/Tools/SetAgentCursorStyleTool.swift b/libs/cua-driver/Sources/CuaDriverServer/Tools/SetAgentCursorStyleTool.swift new file mode 100644 index 0000000000..2caada5b70 --- /dev/null +++ b/libs/cua-driver/Sources/CuaDriverServer/Tools/SetAgentCursorStyleTool.swift @@ -0,0 +1,111 @@ +import CuaDriverCore +import Foundation +import MCP + +/// Set the visual style of the agent-cursor overlay. All fields are +/// optional — omit any field to keep its current value. +/// +/// Changes take effect on the next rendered frame. Style choices are +/// persisted to config so the next daemon restart restores them. +public enum SetAgentCursorStyleTool { + public static let handler = ToolHandler( + tool: Tool( + name: "set_agent_cursor_style", + description: """ + Customize the agent-cursor's visual appearance. All fields + optional; omitted fields keep their current value. + + - gradient_colors: Array of CSS hex strings (#RRGGBB or + #RGB) defining the arrow fill gradient from tip to tail. + E.g. ["#FF6B6B", "#FF8E53"] for a red-orange arrow. + - bloom_color: CSS hex string for the glow halo and the + focus-rect highlight drawn around clicked elements. + - image_path: Absolute or ~-rooted path to a PNG, JPEG, + PDF, or SVG file. When set, replaces the default arrow + with this image (drawn at shapeSize × shapeSize points, + rotated to match the motion heading). Set to "" (empty + string) to revert to the procedural arrow. + + Example — brand-colored arrow: + {"gradient_colors": ["#A855F7", "#6366F1"], "bloom_color": "#A855F7"} + + Example — custom PNG cursor: + {"image_path": "~/cursors/my-cursor.png"} + + Example — revert to default: + {"gradient_colors": [], "bloom_color": "", "image_path": ""} + """, + inputSchema: [ + "type": "object", + "properties": [ + "gradient_colors": [ + "type": "array", + "items": ["type": "string"], + "description": "CSS hex color strings for gradient stops (tip to tail). Empty array reverts to default.", + ], + "bloom_color": [ + "type": "string", + "description": "CSS hex color for the bloom halo and focus rect. Empty string reverts to default.", + ], + "image_path": [ + "type": "string", + "description": "Path to PNG/JPEG/PDF/SVG cursor image. Empty string reverts to arrow.", + ], + ], + "additionalProperties": false, + ], + annotations: .init( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + ) + ), + invoke: { arguments in + // Read current persisted style; we'll mutate only the supplied fields. + var styleConfig = await ConfigStore.shared.load().agentCursor.style + + if let colors = arguments?["gradient_colors"]?.arrayValue { + let hexes = colors.compactMap { $0.stringValue } + styleConfig.gradientColors = hexes.isEmpty ? nil : hexes + } + + if let bloomStr = arguments?["bloom_color"]?.stringValue { + styleConfig.bloomColor = bloomStr.isEmpty ? nil : bloomStr + } + + if let pathStr = arguments?["image_path"]?.stringValue { + styleConfig.imagePath = pathStr.isEmpty ? nil : pathStr + } + + // Apply live so the change is immediate. + await MainActor.run { + AgentCursor.shared.applyStyleConfig(styleConfig) + } + + // Persist. + do { + try await ConfigStore.shared.mutate { config in + config.agentCursor.style = styleConfig + } + } catch { + return CallTool.Result( + content: [.text( + text: "Style applied live but persisting to config failed: \(error.localizedDescription)", + annotations: nil, _meta: nil + )], + isError: true + ) + } + + var parts: [String] = [] + if let gc = styleConfig.gradientColors { parts.append("gradient_colors=[\(gc.joined(separator: ","))]") } + if let bc = styleConfig.bloomColor { parts.append("bloom_color=\(bc)") } + if let ip = styleConfig.imagePath { parts.append("image_path=\(ip)") } + let summary = parts.isEmpty ? "reverted to default" : parts.joined(separator: " ") + return CallTool.Result( + content: [.text(text: "✅ cursor style: \(summary)", annotations: nil, _meta: nil)] + ) + } + ) +}