//! A live status indicator that shows the *latest* log line emitted by the //! application while the agent is processing a long‑running task. use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; use ratatui::buffer::Buffer; use ratatui::layout::Alignment; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_ansi_escape::ansi_escape_line; pub(crate) struct StatusIndicatorWidget { /// Latest text to display (truncated to the available width at render /// time). text: String, frame_idx: Arc, running: Arc, // Keep one sender alive to prevent the channel from closing while the // animation thread is still running. The field itself is currently not // accessed anywhere, therefore the leading underscore silences the // `dead_code` warning without affecting behavior. _app_event_tx: AppEventSender, } impl StatusIndicatorWidget { /// Create a new status indicator and start the animation timer. pub(crate) fn new(app_event_tx: AppEventSender) -> Self { let frame_idx = Arc::new(AtomicUsize::new(0)); let running = Arc::new(AtomicBool::new(true)); // Animation thread. { let frame_idx_clone = Arc::clone(&frame_idx); let running_clone = Arc::clone(&running); let app_event_tx_clone = app_event_tx.clone(); thread::spawn(move || { let mut counter = 0usize; while running_clone.load(Ordering::Relaxed) { std::thread::sleep(Duration::from_millis(100)); counter = counter.wrapping_add(1); frame_idx_clone.store(counter, Ordering::Relaxed); app_event_tx_clone.send(AppEvent::RequestRedraw); } }); } Self { text: String::from("waiting for logs…"), frame_idx, running, _app_event_tx: app_event_tx, } } pub fn desired_height(&self, _width: u16) -> u16 { 1 } /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { self.text = text.replace(['\n', '\r'], " "); } } impl Drop for StatusIndicatorWidget { fn drop(&mut self) { use std::sync::atomic::Ordering; self.running.store(false, Ordering::Relaxed); } } impl WidgetRef for StatusIndicatorWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let widget_style = Style::default(); let block = Block::default() .padding(Padding::new(1, 0, 0, 0)) .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) .border_style(widget_style.dim()); let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); let header_text = "Working"; let header_chars: Vec = header_text.chars().collect(); let padding = 4usize; // virtual padding around the word for smoother loop let period = header_chars.len() + padding * 2; let pos = idx % period; let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) .map(|level| level.has_16m) .unwrap_or(false); // Width of the bright band (in characters). let band_half_width = 2.0; let mut header_spans: Vec> = Vec::new(); for (i, ch) in header_chars.iter().enumerate() { let i_pos = i as isize + padding as isize; let pos = pos as isize; let dist = (i_pos - pos).abs() as f32; let t = if dist <= band_half_width { let x = std::f32::consts::PI * (dist / band_half_width); 0.5 * (1.0 + x.cos()) } else { 0.0 }; let brightness = 0.4 + 0.6 * t; let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; let style = if has_true_color { Style::default() .fg(Color::Rgb(level, level, level)) .add_modifier(Modifier::BOLD) } else { // Bold makes dark gray and gray look the same, so don't use it // when true color is not supported. Style::default().fg(color_for_level(level)) }; header_spans.push(Span::styled(ch.to_string(), style)); } header_spans.push(Span::styled( " ", Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), )); // Ensure we do not overflow width. let inner_width = block.inner(area).width as usize; // Sanitize and colour‑strip the potentially colourful log text. This // ensures that **no** raw ANSI escape sequences leak into the // back‑buffer which would otherwise cause cursor jumps or stray // artefacts when the terminal is resized. let line = ansi_escape_line(&self.text); let mut sanitized_tail: String = line .spans .iter() .map(|s| s.content.as_ref()) .collect::>() .join(""); // Truncate *after* stripping escape codes so width calculation is // accurate. See UTF‑8 boundary comments above. let header_len: usize = header_spans.iter().map(|s| s.content.len()).sum(); if header_len + sanitized_tail.len() > inner_width { let available_bytes = inner_width.saturating_sub(header_len); if sanitized_tail.is_char_boundary(available_bytes) { sanitized_tail.truncate(available_bytes); } else { let mut idx = available_bytes; while idx < sanitized_tail.len() && !sanitized_tail.is_char_boundary(idx) { idx += 1; } sanitized_tail.truncate(idx); } } let mut spans = header_spans; // Re‑apply the DIM modifier so the tail appears visually subdued // irrespective of the colour information preserved by // `ansi_escape_line`. spans.push(Span::styled(sanitized_tail, Style::default().dim())); let paragraph = Paragraph::new(Line::from(spans)) .block(block) .alignment(Alignment::Left); paragraph.render_ref(area, buf); } } fn color_for_level(level: u8) -> Color { if level < 128 { Color::DarkGray } else if level < 192 { Color::Gray } else { Color::White } }