//! 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 crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, show_interrupt_hint: bool, elapsed_running: Duration, last_resume_at: Instant, is_paused: bool, app_event_tx: AppEventSender, frame_requester: FrameRequester, } // Format elapsed seconds into a compact human-friendly form used by the status line. // Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { if elapsed_secs < 60 { return format!("{elapsed_secs}s"); } if elapsed_secs < 3600 { let minutes = elapsed_secs / 60; let seconds = elapsed_secs % 60; return format!("{minutes}m {seconds:02}s"); } let hours = elapsed_secs / 3600; let minutes = (elapsed_secs % 3600) / 60; let seconds = elapsed_secs % 60; format!("{hours}h {minutes:02}m {seconds:02}s") } impl StatusIndicatorWidget { pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { header: String::from("Working"), show_interrupt_hint: true, 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 { 1 } 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) { self.header = header; } pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { self.show_interrupt_hint = visible; } #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.header } #[cfg(test)] pub(crate) fn interrupt_hint_visible(&self) -> bool { self.show_interrupt_hint } 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_duration_at(&self, now: Instant) -> Duration { let mut elapsed = self.elapsed_running; if !self.is_paused { elapsed += now.saturating_duration_since(self.last_resume_at); } elapsed } fn elapsed_seconds_at(&self, now: Instant) -> u64 { self.elapsed_duration_at(now).as_secs() } pub 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 now = Instant::now(); let elapsed_duration = self.elapsed_duration_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let mut spans = Vec::with_capacity(5); spans.push(spinner(Some(self.last_resume_at))); spans.push(" ".into()); spans.extend(shimmer_spans(&self.header)); spans.push(" ".into()); if self.show_interrupt_hint { spans.extend(vec![ format!("({pretty_elapsed} • ").dim(), key_hint::plain(KeyCode::Esc).into(), " to interrupt)".dim(), ]); } else { spans.push(format!("({pretty_elapsed})").dim()); } Line::from(spans).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; use pretty_assertions::assert_eq; #[test] fn fmt_elapsed_compact_formats_seconds_minutes_hours() { assert_eq!(fmt_elapsed_compact(0), "0s"); assert_eq!(fmt_elapsed_compact(1), "1s"); assert_eq!(fmt_elapsed_compact(59), "59s"); assert_eq!(fmt_elapsed_compact(60), "1m 00s"); assert_eq!(fmt_elapsed_compact(61), "1m 01s"); assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m 05s"); assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m 59s"); assert_eq!(fmt_elapsed_compact(3600), "1h 00m 00s"); assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h 01m 01s"); assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h 02m 03s"); } #[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 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); } }