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.
//! Agrupa los trozos de texto del stream para despertar la interfaz pocas veces por segundo.
//!
//! Un modelo rápido emite cientos de eventos pequeños por segundo; repintar la ventana por cada uno gasta CPU, GPU y
//! batería sin que se note. Aquí el texto se junta y se entrega como mucho cada `interval`; cualquier otro evento
//! (herramienta, uso, fin) vacía antes lo pendiente para conservar el orden.

use crate::ai::types::Event;
use std::time::{Duration, Instant};

pub struct TextBatcher {
    interval: Duration,
    pending: String,
    last_flush: Instant,
}

impl TextBatcher {
    pub fn new(interval: Duration) -> Self {
        Self { interval, pending: String::new(), last_flush: Instant::now() }
    }

    /// Procesa un evento y entrega lo que corresponda a `emit`.
    pub fn push(&mut self, event: Event, emit: &mut dyn FnMut(Event)) {
        self.push_at(event, Instant::now(), emit);
    }

    fn push_at(&mut self, event: Event, now: Instant, emit: &mut dyn FnMut(Event)) {
        match event {
            Event::Text(text) => {
                self.pending.push_str(&text);
                if now.duration_since(self.last_flush) >= self.interval {
                    self.flush_at(now, emit);
                }
            }
            other => {
                self.flush_at(now, emit);
                emit(other);
            }
        }
    }

    /// Entrega el texto acumulado (al terminar el stream o al cancelarlo).
    pub fn finish(&mut self, emit: &mut dyn FnMut(Event)) {
        self.flush_at(Instant::now(), emit);
    }

    fn flush_at(&mut self, now: Instant, emit: &mut dyn FnMut(Event)) {
        self.last_flush = now;
        if !self.pending.is_empty() {
            emit(Event::Text(std::mem::take(&mut self.pending)));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ai::types::StopReason;

    fn run(events: Vec<(u64, Event)>, interval_ms: u64) -> Vec<Event> {
        let start = Instant::now();
        let mut batcher =
            TextBatcher { interval: Duration::from_millis(interval_ms), pending: String::new(), last_flush: start };
        let mut out = Vec::new();
        for (at, event) in events {
            batcher.push_at(event, start + Duration::from_millis(at), &mut |event| out.push(event));
        }
        batcher.finish(&mut |event| out.push(event));
        out
    }

    #[test]
    fn junta_texto_dentro_del_intervalo() {
        let events = (0..10).map(|index| (index, Event::Text(format!("{index}")))).collect();
        assert_eq!(run(events, 33), [Event::Text("0123456789".into())]);
    }

    #[test]
    fn entrega_cuando_pasa_el_intervalo() {
        let out =
            run(vec![(0, Event::Text("a".into())), (40, Event::Text("b".into())), (45, Event::Text("c".into()))], 33);
        assert_eq!(out, [Event::Text("ab".into()), Event::Text("c".into())]);
    }

    #[test]
    fn otros_eventos_vacian_antes_para_conservar_el_orden() {
        let tool = Event::ToolUse { id: "t".into(), name: "x".into(), input: serde_json::json!({}) };
        let out = run(vec![(0, Event::Text("a".into())), (1, tool.clone()), (2, Event::Done(StopReason::ToolUse))], 33);
        assert_eq!(out, [Event::Text("a".into()), tool, Event::Done(StopReason::ToolUse)]);
    }
}