mirror of
https://github.com/openai/codex.git
synced 2026-05-02 12:21:26 +03:00
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.
254 lines
8.0 KiB
Rust
254 lines
8.0 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,
|
||
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);
|
||
}
|
||
}
|