This fixes an issue where messages sent during the final response stream would seem to disappear, because the "queued messages" UI wasn't shown during streaming.
243 lines
7.8 KiB
Rust
243 lines
7.8 KiB
Rust
//! 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::<AppEvent>();
|
||
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::<AppEvent>();
|
||
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::<AppEvent>();
|
||
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);
|
||
}
|
||
}
|