Files
llmx/codex-rs/tui/src/status_indicator_widget.rs
Enrique Moreno Tent 6cfc012e9d feat(tui): show minutes/hours in thinking timer (#3220)
What
  
- Show compact elapsed time in the TUI status indicator: Xs, MmSSs,
HhMMmSSs.
  - Add private helper fmt_elapsed_compact with a unit test.
  
  Why
  
- Seconds‑only becomes hard to read during longer runs; minutes/hours
improve clarity without extra noise.
  
  How
  
  - Implemented in codex-rs/tui/src/status_indicator_widget.rs only.
- The helper is used when rendering the existing “Working/Thinking”
timer.
- No changes to codex-common::elapsed::format_duration or other crates.
  
  Scope/Impact
  
  - TUI‑only; no public API changes; minimal risk.
  - Snapshot tests should remain unchanged (most show “0s”).
  
  Before/After
  
- Working (65s • Esc to interrupt) → Working (1m05s • Esc to interrupt)
  - Working (3723s • …) → Working (1h02m03s • …)
  
  Tests
  
  - Unit: fmt_elapsed_compact_formats_seconds_minutes_hours.
- Local checks: cargo fmt --all, cargo clippy -p codex-tui -- -D
warnings, cargo test -p codex-tui.
  
  Notes
  
- Open to adjusting the exact format or moving the helper if maintainers
prefer a shared location.

Signed-off-by: Enrique Moreno Tent <enriquemorenotent@gmail.com>
2025-09-05 22:06:36 +00:00

285 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a longrunning 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;
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
header: String,
/// Queued user messages to display under the status line.
queued_messages: Vec<String>,
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, 1m00s, 59m59s, 1h00m00s, 2h03m09s
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 + 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 {
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;
}
}
/// Replace the queued messages displayed beneath the header.
pub(crate) fn set_queued_messages(&mut self, queued: Vec<String>) {
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();
let pretty_elapsed = fmt_elapsed_compact(elapsed);
// 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!("({pretty_elapsed}").dim(),
"Esc".dim().bold(),
" to interrupt)".dim(),
]);
// Build lines: status, then queued messages, then spacer.
let mut lines: Vec<Line<'static>> = 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
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() {
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;
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), "1m00s");
assert_eq!(fmt_elapsed_compact(61), "1m01s");
assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m05s");
assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m59s");
assert_eq!(fmt_elapsed_compact(3600), "1h00m00s");
assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h01m01s");
assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h02m03s");
}
#[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 renders_with_queued_messages() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
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::<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);
}
}