//! 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::Paragraph; 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, /// 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, } // 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"), 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 + optional blank 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 if !self.queued_messages.is_empty() { total = total.saturating_add(1); // blank line between status and queued messages } let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix if text_width > 0 { for q in &self.queued_messages { let wrapped = textwrap::wrap(q, text_width); 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; } } #[cfg(test)] pub(crate) fn header(&self) -> &str { &self.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_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.extend(vec![ " ".into(), format!("({pretty_elapsed} • ").dim(), key_hint::plain(KeyCode::Esc).into(), " to interrupt)".dim(), ]); // Build lines: status, then queued messages, then spacer. let mut lines: Vec> = Vec::new(); lines.push(Line::from(spans)); if !self.queued_messages.is_empty() { lines.push(Line::from("")); } // Wrap queued messages using textwrap and show up to the first 3 lines per message. let text_width = area.width.saturating_sub(3); // " ↳ " prefix for q in &self.queued_messages { let wrapped = textwrap::wrap(q, text_width as usize); 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() { lines.push( Line::from(vec![ " ".into(), key_hint::alt(KeyCode::Up).into(), " 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; 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 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"); #[cfg(target_os = "macos")] insta::with_settings!({ snapshot_suffix => "macos" }, { insta::assert_snapshot!(terminal.backend()); }); #[cfg(not(target_os = "macos"))] 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); } }