Skip to main content
Glama
MoveCommand.swift14.2 kB
import AppKit import Commander import CoreGraphics import Foundation import PeekabooCore import PeekabooFoundation /// Moves the mouse cursor to specific coordinates or UI elements. @available(macOS 14.0, *) @MainActor struct MoveCommand: ErrorHandlingCommand, OutputFormattable { @Argument(help: "Coordinates as x,y (e.g., 100,200)") var coordinates: String? @Option(help: "Move to element by text/label") var to: String? @Option(help: "Move to element by ID (e.g., B1, T2)") var id: String? @Flag(help: "Move to screen center") var center = false @Flag(help: "Use smooth movement animation") var smooth = false @Option(help: "Movement duration in milliseconds (default: 500 for smooth, 0 for instant)") var duration: Int? @Option(help: "Number of steps for smooth movement (default: 20)") var steps: Int = 20 @Option(help: "Movement profile: linear (default) or human.") var profile: String? @Option(help: "Session ID for element resolution") var session: String? @RuntimeStorage private var runtime: CommandRuntime? private var resolvedRuntime: CommandRuntime { guard let runtime else { preconditionFailure("CommandRuntime must be configured before accessing runtime resources") } return runtime } private var services: any PeekabooServiceProviding { self.resolvedRuntime.services } private var logger: Logger { self.resolvedRuntime.logger } var outputLogger: Logger { self.logger } var jsonOutput: Bool { self.resolvedRuntime.configuration.jsonOutput } mutating func validate() throws { // Ensure at least one target is specified guard self.center || self.coordinates != nil || self.to != nil || self.id != nil else { throw ValidationError("Specify coordinates, --to, --id, or --center") } // Validate coordinates format if provided if let coordString = coordinates { let parts = coordString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } guard parts.count == 2, Double(parts[0]) != nil, Double(parts[1]) != nil else { throw ValidationError("Invalid coordinates format. Use: x,y") } } if let profileName = self.profile?.lowercased(), MovementProfileSelection(rawValue: profileName) == nil { throw ValidationError("Invalid profile '\(profileName)'. Use 'linear' or 'human'.") } } @MainActor mutating func run(using runtime: CommandRuntime) async throws { self.runtime = runtime let startTime = Date() self.logger.setJsonOutputMode(self.jsonOutput) do { // Determine target location let targetLocation: CGPoint let targetDescription: String if self.center { // Move to screen center guard let mainScreen = NSScreen.main else { throw ValidationError("No main screen found") } let screenFrame = mainScreen.frame targetLocation = CGPoint(x: screenFrame.midX, y: screenFrame.midY) targetDescription = "Screen center" } else if let coordString = coordinates { // Parse coordinates let parts = coordString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } let x = Double(parts[0])! let y = Double(parts[1])! targetLocation = CGPoint(x: x, y: y) targetDescription = "Coordinates (\(Int(x)), \(Int(y)))" } else if let elementId = id { // Move to element by ID let sessionId: String? = if let providedSession = session { providedSession } else { await self.services.sessions.getMostRecentSession() } guard let activeSessionId = sessionId else { throw PeekabooError.sessionNotFound("No session found") } guard let detectionResult = try? await self.services.sessions .getDetectionResult(sessionId: activeSessionId), let element = detectionResult.elements.findById(elementId) else { throw PeekabooError.elementNotFound("Element with ID '\(elementId)' not found") } targetLocation = CGPoint(x: element.bounds.midX, y: element.bounds.midY) targetDescription = self.formatElementInfo(element) } else if let query = to { // Find element by text/query let sessionId: String? = if let providedSession = session { providedSession } else { await self.services.sessions.getMostRecentSession() } guard let activeSessionId = sessionId else { throw PeekabooError.sessionNotFound("No session found") } // Wait for element to be available let waitResult = try await AutomationServiceBridge.waitForElement( automation: self.services.automation, target: .query(query), timeout: 5.0, sessionId: activeSessionId ) guard waitResult.found, let element = waitResult.element else { throw PeekabooError.elementNotFound( "No element found matching '\(query)'" ) } targetLocation = CGPoint(x: element.bounds.midX, y: element.bounds.midY) targetDescription = self.formatElementInfo(element) } else { throw ValidationError("Specify coordinates, --to, --id, or --center") } // Get current mouse location for distance calculation let currentLocation = CGEvent(source: nil)?.location ?? CGPoint.zero let distance = hypot( targetLocation.x - currentLocation.x, targetLocation.y - currentLocation.y ) let movement = self.resolveMovementParameters( profileSelection: self.selectedProfile, distance: distance ) // Perform the movement try await AutomationServiceBridge.moveMouse( automation: self.services.automation, to: targetLocation, duration: movement.duration, steps: movement.steps, profile: movement.profile ) AutomationEventLogger.log( .cursor, "move target=\(targetDescription) duration=\(movement.duration)ms steps=\(movement.steps) " + "profile=\(movement.profileName)" ) // Output results let result = MoveResult( success: true, targetLocation: targetLocation, targetDescription: targetDescription, fromLocation: currentLocation, distance: distance, duration: movement.duration, smooth: movement.smooth, profile: movement.profileName, executionTime: Date().timeIntervalSince(startTime) ) output(result) { print("✅ Mouse moved successfully") print("🎯 Target: \(targetDescription)") print("📍 Location: (\(Int(targetLocation.x)), \(Int(targetLocation.y)))") print("📏 Distance: \(Int(distance)) pixels") print("🧭 Profile: \(movement.profileName.capitalized)") if movement.smooth { print("🎬 Animation: \(movement.duration)ms with \(movement.steps) steps") } print("⏱️ Completed in \(String(format: "%.2f", Date().timeIntervalSince(startTime)))s") } } catch { self.handleError(error) throw ExitCode.failure } } private func formatElementInfo(_ element: DetectedElement) -> String { let roleDescription = element.type.rawValue.replacingOccurrences(of: "_", with: " ").capitalized let label = element.label ?? element.value ?? element.id return "\(roleDescription): \(label)" } private var selectedProfile: MovementProfileSelection { guard let profileName = self.profile?.lowercased(), let selection = MovementProfileSelection(rawValue: profileName) else { return .linear } return selection } private func resolveMovementParameters( profileSelection: MovementProfileSelection, distance: CGFloat ) -> MovementParameters { switch profileSelection { case .linear: let resolvedDuration: Int = if let customDuration = self.duration { customDuration } else { self.smooth ? 500 : 0 } let resolvedSteps = self.smooth ? max(self.steps, 1) : 1 return MovementParameters( profile: .linear, duration: resolvedDuration, steps: resolvedSteps, smooth: self.smooth, profileName: profileSelection.rawValue ) case .human: let resolvedDuration = self.duration ?? self.defaultHumanDuration(for: distance) let resolvedSteps = max(self.steps, self.defaultHumanSteps(for: distance)) return MovementParameters( profile: .human(), duration: resolvedDuration, steps: resolvedSteps, smooth: true, profileName: profileSelection.rawValue ) } } private func defaultHumanDuration(for distance: CGFloat) -> Int { let distanceFactor = log2(Double(distance) + 1) * 90 let perPixel = Double(distance) * 0.45 let estimate = 240 + distanceFactor + perPixel return min(max(Int(estimate), 280), 1700) } private func defaultHumanSteps(for distance: CGFloat) -> Int { let scaled = Int(distance * 0.35) return min(max(scaled, 30), 120) } } // MARK: - JSON Output Structure struct MoveResult: Codable { let success: Bool let targetLocation: [String: Double] let targetDescription: String let fromLocation: [String: Double] let distance: Double let duration: Int let smooth: Bool let profile: String let executionTime: TimeInterval init( success: Bool, targetLocation: CGPoint, targetDescription: String, fromLocation: CGPoint, distance: Double, duration: Int, smooth: Bool, profile: String, executionTime: TimeInterval ) { self.success = success self.targetLocation = ["x": targetLocation.x, "y": targetLocation.y] self.targetDescription = targetDescription self.fromLocation = ["x": fromLocation.x, "y": fromLocation.y] self.distance = distance self.duration = duration self.smooth = smooth self.profile = profile self.executionTime = executionTime } } // MARK: - Conformances @MainActor extension MoveCommand: ParsableCommand { nonisolated(unsafe) static var commandDescription: CommandDescription { MainActorCommandDescription.describe { CommandDescription( commandName: "move", abstract: "Move the mouse cursor to coordinates or UI elements", discussion: """ The 'move' command positions the mouse cursor at specific locations or on UI elements detected by 'see'. Supports instant and smooth movement. EXAMPLES: peekaboo move 100,200 # Move to coordinates peekaboo move --to "Submit Button" # Move to element by text peekaboo move --id B3 # Move to element by ID peekaboo move 500,300 --smooth # Smooth movement peekaboo move --center # Move to screen center MOVEMENT MODES: - Instant (default): Immediate cursor positioning - Smooth: Animated movement with configurable duration - Human: Natural arcs with eased velocity, enable via '--profile human' ELEMENT TARGETING: When targeting elements, the cursor moves to the element's center. Use element IDs from 'see' output for precise targeting. """, showHelpOnEmptyInvocation: true ) } } } extension MoveCommand: AsyncRuntimeCommand {} @MainActor extension MoveCommand: CommanderBindableCommand { mutating func applyCommanderValues(_ values: CommanderBindableValues) throws { self.coordinates = try values.decodeOptionalPositional(0, label: "coordinates") self.to = values.singleOption("to") self.id = values.singleOption("id") self.center = values.flag("center") self.smooth = values.flag("smooth") if let duration: Int = try values.decodeOption("duration", as: Int.self) { self.duration = duration } if let steps: Int = try values.decodeOption("steps", as: Int.self) { self.steps = steps } self.session = values.singleOption("session") self.profile = values.singleOption("profile") } } private enum MovementProfileSelection: String { case linear case human } private struct MovementParameters { let profile: MouseMovementProfile let duration: Int let steps: Int let smooth: Bool let profileName: String }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/steipete/Peekaboo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server