Files
codex/codex-rs/tui2/src/status_indicator_widget.rs
Josh McKinney 90f262e9a4 feat(tui2): copy tui crate and normalize snapshots (#7833)
Introduce a full codex-tui source snapshot under the new codex-tui2
crate so viewport work can be replayed in isolation.

This change copies the entire codex-rs/tui/src tree into
codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep
future diffs vs the original viewport bookmark easy to reason about.

The goal is for codex-tui2 to render identically to the existing TUI
behind the `features.tui2` flag while we gradually port the
viewport/history commits from the joshka/viewport bookmark onto this
forked tree.

While on this baseline change, we also ran the codex-tui2 snapshot test
suite and accepted all insta snapshots for the new crate, so the
snapshot files now use the codex-tui2 naming scheme and encode the
unmodified legacy TUI behavior. This keeps later viewport commits
focused on intentional behavior changes (and their snapshots) rather
than on mechanical snapshot renames.
2025-12-10 22:53:46 +00:00

254 lines
8.0 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 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::render::renderable::Renderable;
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,
animations_enabled: bool,
}
// 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,
animations_enabled: bool,
) -> 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,
animations_enabled,
}
}
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;
}
#[cfg(test)]
pub(crate) fn header(&self) -> &str {
&self.header
}
pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) {
self.show_interrupt_hint = visible;
}
#[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 Renderable for StatusIndicatorWidget {
fn desired_height(&self, _width: u16) -> u16 {
1
}
fn render(&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());
let mut spans = Vec::with_capacity(5);
spans.push(spinner(Some(self.last_resume_at), self.animations_enabled));
spans.push(" ".into());
if self.animations_enabled {
spans.extend(shimmer_spans(&self.header));
} else if !self.header.is_empty() {
spans.push(self.header.clone().into());
}
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(), true);
// 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(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(), true);
// 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(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(), true);
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);
}
}