mirror of
https://github.com/openai/codex.git
synced 2026-04-30 11:21:34 +03:00
- introduce RenderableItem to support both owned and borrowed children in composite Renderables - refactor some of our gnarlier manual layouts, BottomPane and ChatWidget, to use ColumnRenderable - Renderable and friends now handle cursor_pos()
244 lines
7.8 KiB
Rust
244 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::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,
|
||
}
|
||
|
||
// 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(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());
|
||
|
||
// 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(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(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);
|
||
}
|
||
}
|