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.
//! Cliente ACP (Agent Client Protocol): permite usar agentes externos (Claude Code, Gemini CLI…) desde el panel.
//! Aura es el cliente: el agente corre como proceso hijo, habla JSON-RPC por stdio (`rpc`) y nos pide permisos y
//! acceso a archivos, que resuelve el workspace.

use crate::ai::mcp::McpConfig;
use crate::ai::rpc::{Handled, Incoming, RpcPeer};
use crate::ai::types::AiError;
use serde_json::{Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
const PROTOCOL_VERSION: u32 = 1;
/// Plazo para completar el inicio de sesión en el navegador.
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PermissionOption {
    pub id: String,
    pub name: String,
    /// `allow_once`, `allow_always`, `reject_once` o `reject_always`.
    pub kind: String,
}

/// Un comando de barra que el agente anuncia (`available_commands_update`): sus skills y comandos propios.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AcpCommand {
    pub name: String,
    pub description: String,
    /// Pista de lo que espera tras el nombre (`input.hint`).
    pub hint: Option<String>,
}

/// Lo que el agente nos cuenta o nos pide. Las peticiones llevan el `request` que hay que devolver al contestar.
#[derive(Clone, Debug, PartialEq)]
pub enum AcpEvent {
    Text(String),
    Thought(String),
    /// Comandos de barra disponibles; reemplazan a los anunciados antes.
    Commands(Vec<AcpCommand>),
    ToolCall {
        id: String,
        title: String,
        status: String,
    },
    ToolUpdate {
        id: String,
        status: String,
        output: String,
        /// Título nuevo (por ejemplo el comando, que llega después de abrir la herramienta).
        title: Option<String>,
    },
    Permission {
        request: Value,
        title: String,
        options: Vec<PermissionOption>,
    },
    ReadFile {
        request: Value,
        path: String,
        line: Option<usize>,
        limit: Option<usize>,
    },
    WriteFile {
        request: Value,
        path: String,
        content: String,
    },
    /// El turno terminó con este motivo (`end_turn`, `cancelled`…).
    Done(String),
    Error(String),
}

type Sink = Arc<dyn Fn(AcpEvent) + Send + Sync>;

fn text_of(content: &Value) -> String {
    content["text"].as_str().unwrap_or_default().to_owned()
}

/// El comando de una herramienta de terminal, si el agente lo manda en `rawInput`: es mejor título que «Terminal».
fn raw_command(update: &Value) -> Option<String> {
    update["rawInput"]["command"].as_str().filter(|command| !command.trim().is_empty()).map(str::to_owned)
}

fn update(params: &Value) -> Option<AcpEvent> {
    let update = &params["update"];
    let id = || update["toolCallId"].as_str().unwrap_or_default().to_owned();
    let status = || update["status"].as_str().unwrap_or("pending").to_owned();
    match update["sessionUpdate"].as_str()? {
        "agent_message_chunk" => Some(AcpEvent::Text(text_of(&update["content"]))),
        "agent_thought_chunk" => Some(AcpEvent::Thought(text_of(&update["content"]))),
        "available_commands_update" => Some(AcpEvent::Commands(
            update["availableCommands"]
                .as_array()
                .into_iter()
                .flatten()
                .filter_map(|command| {
                    let name = command["name"].as_str()?.trim_start_matches('/').to_owned();
                    Some(AcpCommand {
                        name,
                        description: command["description"].as_str().unwrap_or_default().to_owned(),
                        hint: command["input"]["hint"].as_str().map(str::to_owned),
                    })
                })
                .collect(),
        )),
        "tool_call" => Some(AcpEvent::ToolCall {
            id: id(),
            title: raw_command(update).unwrap_or_else(|| update["title"].as_str().unwrap_or("herramienta").to_owned()),
            status: status(),
        }),
        "tool_call_update" => {
            let output = update["content"]
                .as_array()
                .into_iter()
                .flatten()
                .map(|part| text_of(&part["content"]))
                .filter(|text| !text.is_empty())
                .collect::<Vec<_>>()
                .join("\n");
            let title = raw_command(update).or_else(|| update["title"].as_str().map(str::to_owned));
            Some(AcpEvent::ToolUpdate { id: id(), status: status(), output, title })
        }
        _ => None,
    }
}

fn route(incoming: Incoming, sink: &Sink) -> Handled {
    match incoming {
        Incoming::Notification { method, params } => {
            if method == "session/update"
                && let Some(event) = update(&params)
            {
                sink(event);
            }
            Handled::Later
        }
        Incoming::Request { id, method, params } => {
            let event = match method.as_str() {
                "session/request_permission" => AcpEvent::Permission {
                    request: id,
                    title: params["toolCall"]["title"].as_str().unwrap_or("una acción").to_owned(),
                    options: params["options"]
                        .as_array()
                        .into_iter()
                        .flatten()
                        .map(|option| PermissionOption {
                            id: option["optionId"].as_str().unwrap_or_default().to_owned(),
                            name: option["name"].as_str().unwrap_or_default().to_owned(),
                            kind: option["kind"].as_str().unwrap_or_default().to_owned(),
                        })
                        .collect(),
                },
                "fs/read_text_file" => AcpEvent::ReadFile {
                    request: id,
                    path: params["path"].as_str().unwrap_or_default().to_owned(),
                    line: params["line"].as_u64().map(|line| line as usize),
                    limit: params["limit"].as_u64().map(|limit| limit as usize),
                },
                "fs/write_text_file" => AcpEvent::WriteFile {
                    request: id,
                    path: params["path"].as_str().unwrap_or_default().to_owned(),
                    content: params["content"].as_str().unwrap_or_default().to_owned(),
                },
                _ => return Handled::Unsupported,
            };
            sink(event);
            Handled::Later
        }
    }
}

pub struct AcpClient {
    peer: RpcPeer,
    session_id: String,
    sink: Sink,
}

impl AcpClient {
    /// Arranca el agente y abre una sesión en `cwd` (bloqueante: llamar desde un hilo).
    pub fn start(
        config: &McpConfig,
        cwd: &Path,
        on_event: impl Fn(AcpEvent) + Send + Sync + 'static,
    ) -> Result<Self, AiError> {
        Self::start_resuming(config, cwd, None, on_event)
    }

    /// Como `start`, pero con `resume` intenta retomar esa sesión: así el agente puede soltarse de memoria entre
    /// turnos sin perder la conversación. Si el agente no sabe reanudar o la sesión ya no existe, abre una nueva.
    pub fn start_resuming(
        config: &McpConfig,
        cwd: &Path,
        resume: Option<&str>,
        on_event: impl Fn(AcpEvent) + Send + Sync + 'static,
    ) -> Result<Self, AiError> {
        let sink: Sink = Arc::new(on_event);
        let handler_sink = sink.clone();
        let peer = RpcPeer::spawn(&config.command, &config.args, &config.env, Some(cwd), move |incoming| {
            route(incoming, &handler_sink)
        })?;
        let capabilities = json!({ "fs": { "readTextFile": true, "writeTextFile": true } });
        let init = peer.request(
            "initialize",
            json!({ "protocolVersion": PROTOCOL_VERSION, "clientCapabilities": capabilities }),
            HANDSHAKE_TIMEOUT,
        )?;
        let params = |extra: Value| {
            let mut params = json!({ "cwd": cwd.to_string_lossy(), "mcpServers": [] });
            params
                .as_object_mut()
                .into_iter()
                .for_each(|map| map.extend(extra.as_object().cloned().unwrap_or_default()));
            params
        };
        let resumable = init["agentCapabilities"]["sessionCapabilities"]["resume"].is_object();
        let resumed = resume
            .filter(|_| resumable)
            .filter(|id| peer.request("session/resume", params(json!({ "sessionId": id })), HANDSHAKE_TIMEOUT).is_ok());
        if let Some(id) = resumed {
            return Ok(Self { peer, session_id: id.to_owned(), sink });
        }
        let session = peer.request("session/new", params(json!({})), HANDSHAKE_TIMEOUT)?;
        let session_id = session["sessionId"]
            .as_str()
            .ok_or_else(|| AiError::Decode("session/new sin sessionId".into()))?
            .to_owned();
        Ok(Self { peer, session_id, sink })
    }

    /// Identificador de la sesión abierta, para retomarla con `start_resuming`.
    pub fn session_id(&self) -> &str {
        &self.session_id
    }

    /// Inicia sesión con un método de ACP (`authenticate`): el agente abre el navegador y contesta al terminar.
    /// Bloqueante: llamar desde un hilo. No abre sesión de conversación.
    pub fn authenticate(config: &McpConfig, cwd: &Path, method: &str) -> Result<(), AiError> {
        let peer = RpcPeer::spawn(&config.command, &config.args, &config.env, Some(cwd), |_| Handled::Unsupported)?;
        peer.request(
            "initialize",
            json!({ "protocolVersion": PROTOCOL_VERSION, "clientCapabilities": {} }),
            HANDSHAKE_TIMEOUT,
        )?;
        peer.request("authenticate", json!({ "methodId": method }), AUTH_TIMEOUT).map(|_| ())
    }

    /// Envía un mensaje; el avance llega como eventos y el final como `Done` o `Error`.
    pub fn prompt(&self, text: &str) -> Result<(), AiError> {
        let sink = self.sink.clone();
        let params = json!({ "sessionId": self.session_id, "prompt": [{ "type": "text", "text": text }] });
        self.peer.request_async("session/prompt", params, move |result| match result {
            Ok(value) => sink(AcpEvent::Done(value["stopReason"].as_str().unwrap_or("end_turn").to_owned())),
            Err(error) => sink(AcpEvent::Error(error.to_string())),
        })
    }

    pub fn cancel(&self) -> Result<(), AiError> {
        self.peer.notify("session/cancel", json!({ "sessionId": self.session_id }))
    }

    /// Responde a un permiso: `Some(opción)` elige esa opción; `None` cancela.
    pub fn answer_permission(&self, request: &Value, option: Option<&str>) -> Result<(), AiError> {
        let outcome = match option {
            Some(option_id) => json!({ "outcome": "selected", "optionId": option_id }),
            None => json!({ "outcome": "cancelled" }),
        };
        self.peer.respond(request, Ok(json!({ "outcome": outcome })))
    }

    /// Responde a una petición de archivo con su resultado o con un error.
    pub fn answer(&self, request: &Value, result: Result<Value, String>) -> Result<(), AiError> {
        self.peer.respond(request, result)
    }
}

#[cfg(test)]
#[path = "acp_tests.rs"]
mod tests;