aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Herramienta `computer` de Aura: ver la pantalla y usar ratón y teclado, como el «computer use» de Claude pero
//! propio del editor (funciona con cualquier proveedor que acepte imágenes). Aquí solo está el contrato: qué
//! acciones hay, cómo se validan y cómo se describen al modelo; el editor las ejecuta.

use crate::ai::types::ToolSpec;
use serde_json::{Value, json};

#[derive(Clone, Debug, PartialEq)]
pub enum ComputerAction {
    /// Captura la pantalla; el resultado lleva la imagen y su tamaño (las coordenadas usan ese tamaño).
    Screenshot,
    Click {
        x: i32,
        y: i32,
        button: MouseButton,
        double: bool,
    },
    /// Mueve el puntero sin pulsar (para menús que se abren al pasar).
    Move {
        x: i32,
        y: i32,
    },
    Type {
        text: String,
    },
    /// Combinación de teclas: `cmd+c`, `return`, `shift+tab`, `ctrl+alt+delete`…
    Key {
        keys: String,
    },
    /// Rueda en `(x, y)`: positivo baja, negativo sube (en líneas).
    Scroll {
        x: i32,
        y: i32,
        amount: i32,
    },
    /// Abre una dirección web en el navegador predeterminado y devuelve la captura cuando carga.
    OpenUrl {
        url: String,
    },
}

/// `github.com` → `https://github.com`; solo se abren direcciones web (nada de `file:` ni esquemas raros).
pub fn web_url(raw: &str) -> Result<String, String> {
    let raw = raw.trim();
    let url = if raw.contains("://") { raw.to_owned() } else { format!("https://{raw}") };
    let valid = (url.starts_with("https://") || url.starts_with("http://"))
        && url.len() > "https://".len()
        && !url.contains(char::is_whitespace);
    if valid { Ok(url) } else { Err(format!("computer: «{raw}» no es una dirección web")) }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
    Left,
    Right,
}

impl ComputerAction {
    pub fn parse(input: &Value) -> Result<Self, String> {
        let number = |key: &str| {
            input[key].as_f64().map(|value| value.round() as i32).ok_or_else(|| format!("computer: falta «{key}»"))
        };
        let text = |key: &str| input[key].as_str().map(str::to_owned).ok_or_else(|| format!("computer: falta «{key}»"));
        match input["action"].as_str().unwrap_or_default() {
            "screenshot" => Ok(Self::Screenshot),
            "click" => Ok(Self::Click {
                x: number("x")?,
                y: number("y")?,
                button: if input["button"].as_str() == Some("right") { MouseButton::Right } else { MouseButton::Left },
                double: input["double"].as_bool().unwrap_or(false),
            }),
            "move" => Ok(Self::Move { x: number("x")?, y: number("y")? }),
            "type" => Ok(Self::Type { text: text("text")? }),
            "key" => Ok(Self::Key { keys: text("keys")? }),
            "open_url" => Ok(Self::OpenUrl { url: web_url(&text("url")?)? }),
            "scroll" => Ok(Self::Scroll { x: number("x")?, y: number("y")?, amount: number("amount")?.clamp(-50, 50) }),
            other => Err(format!(
                "computer: acción «{other}» desconocida; usa screenshot, click, move, type, key, scroll u open_url"
            )),
        }
    }

    /// Resumen de una línea para el panel y la confirmación.
    pub fn summary(&self) -> String {
        match self {
            Self::Screenshot => "Ver la pantalla".into(),
            Self::Click { x, y, button, double } => {
                let kind = match (button, double) {
                    (MouseButton::Right, _) => "Clic derecho",
                    (_, true) => "Doble clic",
                    _ => "Clic",
                };
                format!("{kind} en ({x}, {y})")
            }
            Self::Move { x, y } => format!("Mover el puntero a ({x}, {y})"),
            Self::Type { text } => {
                let short: String = text.chars().take(40).collect();
                format!("Escribir «{short}»")
            }
            Self::Key { keys } => format!("Pulsar {keys}"),
            Self::Scroll { amount, .. } => format!("Desplazar {amount} líneas"),
            Self::OpenUrl { url } => format!("Abrir {url}"),
        }
    }
}

pub fn spec() -> ToolSpec {
    ToolSpec {
        name: "computer".into(),
        description: "Controla el computador del usuario: ve la pantalla y usa ratón y teclado. Empieza siempre con \
            `screenshot`; las coordenadas x, y son píxeles de la última captura (origen arriba a la izquierda). \
            Después de cada clic o tecla que cambie algo, vuelve a capturar para comprobar el resultado. Para la web \
            usa `open_url`: abre el navegador y devuelve la captura. Úsala solo cuando la tarea necesite otra aplicación; para el código del proyecto usa las demás herramientas."
            .into(),
        schema: json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["screenshot", "click", "move", "type", "key", "scroll", "open_url"],
                },
                "url": { "type": "string", "description": "Dirección web a abrir (action=open_url)" },
                "x": { "type": "number" },
                "y": { "type": "number" },
                "button": { "type": "string", "enum": ["left", "right"] },
                "double": { "type": "boolean" },
                "text": { "type": "string", "description": "Texto a escribir (action=type)" },
                "keys": { "type": "string", "description": "Teclas con +, por ejemplo cmd+c, return, shift+tab" },
                "amount": { "type": "number", "description": "Líneas a desplazar: positivo baja" },
            },
            "required": ["action"],
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn interpreta_cada_accion_y_explica_lo_que_falta() {
        assert_eq!(ComputerAction::parse(&json!({"action": "screenshot"})), Ok(ComputerAction::Screenshot));
        assert_eq!(
            ComputerAction::parse(&json!({"action": "click", "x": 10.4, "y": 20, "button": "right"})),
            Ok(ComputerAction::Click { x: 10, y: 20, button: MouseButton::Right, double: false })
        );
        assert_eq!(
            ComputerAction::parse(&json!({"action": "scroll", "x": 1, "y": 2, "amount": 900})),
            Ok(ComputerAction::Scroll { x: 1, y: 2, amount: 50 })
        );
        assert!(ComputerAction::parse(&json!({"action": "click", "x": 1})).unwrap_err().contains("«y»"));
        assert!(ComputerAction::parse(&json!({"action": "volar"})).unwrap_err().contains("desconocida"));
        assert_eq!(ComputerAction::Key { keys: "cmd+c".into() }.summary(), "Pulsar cmd+c");
        assert_eq!(
            ComputerAction::parse(&json!({"action": "open_url", "url": "github.com"})),
            Ok(ComputerAction::OpenUrl { url: "https://github.com".into() })
        );
        assert!(web_url("file:///etc/passwd").is_err() && web_url("hola mundo").is_err());
    }
}