//! Presentación en el terminal. //! //! Una sola línea viva que se reescribe con la transcripción provisional, y //! líneas fijas para lo que ya es definitivo. La distinción importa: ver que //! el asistente te está oyendo *mientras* hablas es lo que hace que la espera //! se note menos de lo que dura. use std::io::Write; use std::time::Duration; use asist_core::event::{Event, InterruptReason}; const RESET: &str = "\x1b[0m"; const DIM: &str = "\x1b[2m"; const BOLD: &str = "\x1b[1m"; const CYAN: &str = "\x1b[36m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; const RED: &str = "\x1b[31m"; pub struct Renderer { width: usize, line_open: bool, color: bool, show_partials: bool, reply: String, } impl Renderer { pub fn new(show_partials: bool) -> Self { Self { width: terminal_width(), line_open: false, // Sin terminal interactivo, los códigos de color sólo ensucian // el fichero de registro. color: std::env::var_os("NO_COLOR").is_none() && is_tty(), show_partials, reply: String::new(), } } fn paint(&self, code: &str, text: &str) -> String { if self.color { format!("{code}{text}{RESET}") } else { text.to_string() } } /// Borra la línea viva para poder escribir encima algo definitivo. fn close_line(&mut self) { if self.line_open { print!("\r\x1b[K"); self.line_open = false; } } fn live(&mut self, text: &str) { let mut shown: String = text.chars().collect(); if shown.chars().count() > self.width.saturating_sub(4) { let keep = self.width.saturating_sub(7); let skip = shown.chars().count() - keep; shown = format!("…{}", shown.chars().skip(skip).collect::()); } print!("\r\x1b[K{shown}"); let _ = std::io::stdout().flush(); self.line_open = true; } pub fn apply(&mut self, event: &Event) { match event { Event::SpeechStarted { .. } => { self.reply.clear(); if self.show_partials { self.live(&self.paint(DIM, "escuchando…")); } } Event::Partial { committed, volatile, .. } => { if !self.show_partials { return; } let line = format!( "{} {}", self.paint(DIM, committed), self.paint(DIM, volatile) ); self.live(&line); } Event::Transcript { text, audio_secs, decode, .. } => { self.close_line(); println!( "{} {} {}", self.paint(BOLD, "tú >"), text, self.paint( DIM, &format!("({audio_secs:.1} s, asr {} ms)", decode.as_millis()) ) ); } Event::Discarded { .. } => { self.close_line(); } Event::ReplyStarted { ttft, .. } => { self.close_line(); print!("{} ", self.paint(CYAN, "asistente >")); let _ = std::io::stdout().flush(); tracing::debug!(target: "render", ttft_ms = ttft.as_millis(), "primer token"); } Event::ReplyDelta { text, .. } => { self.reply.push_str(text); print!("{text}"); let _ = std::io::stdout().flush(); } Event::ReplyDone { .. } => { println!(); } Event::ToolRequested { name, arguments, .. } => { self.close_line(); println!( "{} {name} {}", self.paint(YELLOW, "herramienta >"), self.paint(DIM, &short(arguments, 120)) ); } Event::ToolFinished { name, ok, output, took, .. } => { let mark = if *ok { self.paint(GREEN, "ok") } else { self.paint(RED, "error") }; println!( "{} {name} {mark} {}", self.paint(YELLOW, "herramienta <"), self.paint( DIM, &format!("{} · {} ms", short(output, 120), took.as_millis()) ) ); } Event::AudioStarted { latency, .. } => { tracing::info!( target: "latencia", ms = latency.as_millis(), "primer audio (latencia percibida)" ); if self.color { println!( "{}", self.paint(DIM, &format!(" ▸ voz en {}", human(*latency))) ); } } Event::Sentence { index, text, .. } => { tracing::debug!(target: "render", frase = index, %text, "a sintetizar"); } Event::AudioFinished { .. } => {} Event::Interrupted { reason, .. } => { self.close_line(); let why = match reason { InterruptReason::UserSpoke => "te has adelantado", InterruptReason::Requested => "cortado", }; println!("{}", self.paint(YELLOW, &format!(" ▪ {why}"))); } Event::Warning { message, .. } => { self.close_line(); println!("{} {message}", self.paint(YELLOW, "aviso >")); } Event::Failed { message, .. } => { self.close_line(); println!("{} {message}", self.paint(RED, "error >")); } Event::Shutdown => { self.close_line(); } } } pub fn finish(&mut self) { self.close_line(); let _ = std::io::stdout().flush(); } } fn short(text: &str, max: usize) -> String { let text = text.replace('\n', " "); if text.chars().count() <= max { return text; } text.chars().take(max).collect::() + "…" } fn human(d: Duration) -> String { if d.as_millis() < 1000 { format!("{} ms", d.as_millis()) } else { format!("{:.1} s", d.as_secs_f32()) } } fn terminal_width() -> usize { std::env::var("COLUMNS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(100) } fn is_tty() -> bool { #[cfg(unix)] { unsafe extern "C" { fn isatty(fd: i32) -> i32; } unsafe { isatty(1) == 1 } } #[cfg(not(unix))] true } #[cfg(test)] mod tests { use super::*; #[test] fn el_texto_largo_se_recorta_con_puntos_suspensivos() { assert_eq!(short("abcdefghij", 5), "abcde…"); assert_eq!(short("abc", 5), "abc"); } #[test] fn los_saltos_de_linea_no_rompen_la_linea_viva() { assert_eq!(short("a\nb", 10), "a b"); } #[test] fn las_duraciones_se_leen_en_la_unidad_adecuada() { assert_eq!(human(Duration::from_millis(430)), "430 ms"); assert_eq!(human(Duration::from_millis(2500)), "2.5 s"); } }