//! 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::time::Duration; use std::time::Instant; use codex_core::protocol::Op; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use textwrap::Options as TwOptions; use textwrap::WordSplitter; pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, /// Queued user messages to display under the status line. queued_messages: Vec, elapsed_running: Duration, last_resume_at: Instant, is_paused: bool, app_event_tx: AppEventSender, frame_requester: FrameRequester, } impl StatusIndicatorWidget { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { header: String::from("Working"), queued_messages: Vec::new(), elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, app_event_tx, frame_requester, } } pub fn desired_height(&self, width: u16) -> u16 { // Status line + wrapped queued messages (up to 3 lines per message) // + optional ellipsis line per truncated message + 1 spacer line let inner_width = width.max(1) as usize; let mut total: u16 = 1; // status line let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix if text_width > 0 { let opts = TwOptions::new(text_width) .break_words(false) .word_splitter(WordSplitter::NoHyphenation); for q in &self.queued_messages { let wrapped = textwrap::wrap(q, &opts); let lines = wrapped.len().min(3) as u16; total = total.saturating_add(lines); if wrapped.len() > 3 { total = total.saturating_add(1); // ellipsis line } } if !self.queued_messages.is_empty() { total = total.saturating_add(1); // keybind hint line } } else { // At least one line per message if width is extremely narrow total = total.saturating_add(self.queued_messages.len() as u16); } total.saturating_add(1) // spacer line } pub(crate) fn interrupt(&self) { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); } /// Update the animated header label (left of the brackets). pub(crate) fn update_header(&mut self, header: String) { if self.header != header { self.header = header; } } /// Replace the queued messages displayed beneath the header. pub(crate) fn set_queued_messages(&mut self, queued: Vec) { self.queued_messages = queued; // Ensure a redraw so changes are visible. self.frame_requester.schedule_frame(); } pub(crate) fn pause_timer(&mut self) { self.pause_timer_at(Instant::now()); } pub(crate) fn resume_timer(&mut self) { self.resume_timer_at(Instant::now()); } pub(crate) fn pause_timer_at(&mut self, now: Instant) { if self.is_paused { return; } self.elapsed_running += now.saturating_duration_since(self.last_resume_at); self.is_paused = true; } pub(crate) fn resume_timer_at(&mut self, now: Instant) { if !self.is_paused { return; } self.last_resume_at = now; self.is_paused = false; self.frame_requester.schedule_frame(); } fn elapsed_seconds_at(&self, now: Instant) -> u64 { let mut elapsed = self.elapsed_running; if !self.is_paused { elapsed += now.saturating_duration_since(self.last_resume_at); } elapsed.as_secs() } fn elapsed_seconds(&self) -> u64 { self.elapsed_seconds_at(Instant::now()) } } impl WidgetRef for StatusIndicatorWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; } // Schedule next animation frame. self.frame_requester .schedule_frame_in(Duration::from_millis(32)); let elapsed = self.elapsed_seconds(); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let mut spans = vec![" ".into()]; spans.extend(shimmer_spans(&self.header)); spans.extend(vec![ " ".into(), format!("({elapsed}s • ").dim(), "Esc".dim().bold(), " to interrupt)".dim(), ]); // Build lines: status, then queued messages, then spacer. let mut lines: Vec> = Vec::new(); lines.push(Line::from(spans)); // Wrap queued messages using textwrap and show up to the first 3 lines per message. let text_width = area.width.saturating_sub(3); // " ↳ " prefix let opts = TwOptions::new(text_width as usize) .break_words(false) .word_splitter(WordSplitter::NoHyphenation); for q in &self.queued_messages { let wrapped = textwrap::wrap(q, &opts); for (i, piece) in wrapped.iter().take(3).enumerate() { let prefix = if i == 0 { " ↳ " } else { " " }; let content = format!("{prefix}{piece}"); lines.push(Line::from(content.dim().italic())); } if wrapped.len() > 3 { lines.push(Line::from(" …".dim().italic())); } } if !self.queued_messages.is_empty() { let shortcut = key_hint::alt("↑"); lines.push(Line::from(vec![" ".into(), shortcut, " edit".into()]).dim()); } let paragraph = Paragraph::new(lines); paragraph.render_ref(area, buf); } } #[cfg(test)] mod tests { use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use ratatui::Terminal; use ratatui::backend::TestBackend; use std::time::Duration; use std::time::Instant; use tokio::sync::mpsc::unbounded_channel; #[test] fn renders_with_working_header() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); terminal .draw(|f| w.render_ref(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } #[test] fn renders_truncated() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); terminal .draw(|f| w.render_ref(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } #[test] fn renders_with_queued_messages() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.set_queued_messages(vec!["first".to_string(), "second".to_string()]); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("terminal"); terminal .draw(|f| w.render_ref(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut widget = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); let baseline = Instant::now(); widget.last_resume_at = baseline; let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5)); assert_eq!(before_pause, 5); widget.pause_timer_at(baseline + Duration::from_secs(5)); let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10)); assert_eq!(paused_elapsed, before_pause); widget.resume_timer_at(baseline + Duration::from_secs(10)); let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); assert_eq!(after_resume, before_pause + 3); } }