From 1f538dcb07eec5ee414e830a0c17e6deb9401495 Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Mon, 4 Aug 2025 15:42:03 -0700 Subject: [PATCH] wip --- codex-rs/tui/src/app.rs | 95 ++++++++++------ codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/history_cell.rs | 116 ++------------------ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/shimmer_text.rs | 97 ++++++++++++++++ codex-rs/tui/src/status_indicator_widget.rs | 99 ++--------------- 6 files changed, 181 insertions(+), 233 deletions(-) create mode 100644 codex-rs/tui/src/shimmer_text.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1142bd87fc..4ab89a661f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,6 +5,7 @@ use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; +use crate::shimmer_text::init_process_start; use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; @@ -21,13 +22,10 @@ use ratatui::layout::Offset; use ratatui::prelude::Backend; use ratatui::text::Line; use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; use std::sync::mpsc::Receiver; use std::sync::mpsc::channel; -use std::thread; use std::time::Duration; +use std::time::Instant; /// Time window for debouncing redraw requests. const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10); @@ -55,9 +53,6 @@ pub(crate) struct App<'a> { file_search: FileSearchManager, - /// True when a redraw has been scheduled but not yet executed. - pending_redraw: Arc, - pending_history_lines: Vec>, /// Stored parameters needed to instantiate the ChatWidget later, e.g., @@ -65,6 +60,10 @@ pub(crate) struct App<'a> { chat_args: Option, enhanced_keys_supported: bool, + + /// Channel to schedule one-shot animation frames; coalesced by a single + /// scheduler thread. + frame_schedule_tx: std::sync::mpsc::Sender, } /// Aggregate parameters needed to create a `ChatWidget`, as creation may be @@ -86,7 +85,6 @@ impl App<'_> { ) -> Self { let (app_event_tx, app_event_rx) = channel(); let app_event_tx = AppEventSender::new(app_event_tx); - let pending_redraw = Arc::new(AtomicBool::new(false)); let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); @@ -133,6 +131,9 @@ impl App<'_> { }); } + // Initialize process start time for synchronized animations. + init_process_start(); + let (app_state, chat_args) = if show_git_warning { ( AppState::GitWarning { @@ -162,6 +163,50 @@ impl App<'_> { }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + + // Spawn a single scheduler thread that coalesces both debounced redraw + // requests and animation frame requests, and emits a single Redraw event + // at the earliest requested time. + let (frame_tx, frame_rx) = channel::(); + { + let app_event_tx = app_event_tx.clone(); + std::thread::spawn(move || { + use std::sync::mpsc::RecvTimeoutError; + let mut next_deadline: Option = None; + loop { + // If no scheduled deadline, block until we get one. + if next_deadline.is_none() { + match frame_rx.recv() { + Ok(deadline) => next_deadline = Some(deadline), + Err(_) => break, // channel closed; exit thread + } + } + + #[allow(clippy::expect_used)] + let deadline = next_deadline.expect("set above"); + let now = Instant::now(); + let timeout = if deadline > now { + deadline - now + } else { + Duration::from_millis(0) + }; + + match frame_rx.recv_timeout(timeout) { + Ok(new_deadline) => { + // Coalesce by keeping the earliest deadline. + next_deadline = + Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline))); + } + Err(RecvTimeoutError::Timeout) => { + // Fire once, then clear the deadline. + app_event_tx.send(AppEvent::Redraw); + next_deadline = None; + } + Err(RecvTimeoutError::Disconnected) => break, + } + } + }); + } Self { app_event_tx, pending_history_lines: Vec::new(), @@ -169,9 +214,9 @@ impl App<'_> { app_state, config, file_search, - pending_redraw, chat_args, enhanced_keys_supported, + frame_schedule_tx: frame_tx, } } @@ -181,32 +226,13 @@ impl App<'_> { self.app_event_tx.clone() } - /// Schedule a redraw if one is not already pending. - #[allow(clippy::unwrap_used)] - fn schedule_redraw(&self) { - // Attempt to set the flag to `true`. If it was already `true`, another - // redraw is already pending so we can return early. - if self - .pending_redraw - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - let tx = self.app_event_tx.clone(); - let pending_redraw = self.pending_redraw.clone(); - thread::spawn(move || { - thread::sleep(REDRAW_DEBOUNCE); - tx.send(AppEvent::Redraw); - pending_redraw.store(false, Ordering::SeqCst); - }); + fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); } pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { - // Insert an event to trigger the first render. - let app_event_tx = self.app_event_tx.clone(); - app_event_tx.send(AppEvent::RequestRedraw); + // Trigger the first render immediately via the frame scheduler. + let _ = self.frame_schedule_tx.send(Instant::now()); while let Ok(event) = self.app_event_rx.recv() { match event { @@ -215,7 +241,10 @@ impl App<'_> { self.app_event_tx.send(AppEvent::RequestRedraw); } AppEvent::RequestRedraw => { - self.schedule_redraw(); + self.schedule_frame_in(REDRAW_DEBOUNCE); + } + AppEvent::ScheduleFrameIn(dur) => { + self.schedule_frame_in(dur); } AppEvent::Redraw => { std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 77a600d304..fe7fa5ceb0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -2,6 +2,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use ratatui::text::Line; +use std::time::Duration; use crate::slash_command::SlashCommand; @@ -15,6 +16,11 @@ pub(crate) enum AppEvent { /// Actually draw the next frame. Redraw, + /// Schedule periodic frames from the main loop. The first frame will be + /// scheduled roughly after the provided duration and continue at that + /// cadence until the application exits. + ScheduleFrameIn(Duration), + KeyEvent(KeyEvent), /// Text pasted from the terminal clipboard. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 3f053d8426..075e2c05af 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -31,15 +31,12 @@ use ratatui::widgets::Wrap; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; use std::time::Duration; use tracing::error; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::shimmer_text::shimmer_spans; pub(crate) struct CommandOutput { pub(crate) exit_code: i32, @@ -686,104 +683,22 @@ impl WidgetRef for &HistoryCell { pub(crate) struct ActiveExecCommandView { command: String, - frame_idx: Arc, - running: Arc, + _app_event_tx: AppEventSender, } impl ActiveExecCommandView { fn new(command: String, app_event_tx: AppEventSender) -> Self { - let frame_idx = Arc::new(AtomicUsize::new(0)); - let running = Arc::new(AtomicBool::new(true)); - - // Animation thread to drive shimmer and trigger redraws. - { - let frame_idx_clone = Arc::clone(&frame_idx); - let running_clone = Arc::clone(&running); - let app_event_tx_clone = app_event_tx.clone(); - std::thread::spawn(move || { - let mut counter = 0usize; - while running_clone.load(Ordering::Relaxed) { - std::thread::sleep(std::time::Duration::from_millis(100)); - counter = counter.wrapping_add(1); - frame_idx_clone.store(counter, Ordering::Relaxed); - app_event_tx_clone.send(AppEvent::RequestRedraw); - } - }); - } - Self { command, - frame_idx, - running, + _app_event_tx: app_event_tx, } } - - fn shimmering_header_spans(&self) -> Vec> { - let header_text = "command"; - let header_chars: Vec = header_text.chars().collect(); - - let padding = 4usize; // virtual padding around the word for smoother loop - let period = header_chars.len() + padding * 2; - let pos = self.frame_idx.load(Ordering::Relaxed) % period; - - let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false); - - // Width of the bright band (in characters). - let band_half_width = 2.0; - - let mut header_spans: Vec> = Vec::new(); - for (i, ch) in header_chars.iter().enumerate() { - let i_pos = i as isize + padding as isize; - let pos = pos as isize; - let dist = (i_pos - pos).abs() as f32; - - let t = if dist <= band_half_width { - let x = std::f32::consts::PI * (dist / band_half_width); - 0.5 * (1.0 + x.cos()) - } else { - 0.0 - }; - - let brightness = 0.4 + 0.6 * t; - let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; - let style = if has_true_color { - Style::default() - .fg(Color::Rgb(level, level, level)) - .add_modifier(Modifier::BOLD) - } else { - // Bold makes dark gray and gray look the same, so don't use it - // when true color is not supported. - Style::default().fg(color_for_level(level)) - }; - - header_spans.push(Span::styled(ch.to_string(), style)); - } - - header_spans - } -} - -impl Drop for ActiveExecCommandView { - fn drop(&mut self) { - self.running.store(false, Ordering::Relaxed); - } } impl DynamicHeightWidgetRef for &ActiveExecCommandView { fn desired_height(&self, width: u16) -> u16 { - let mut spans = self.shimmering_header_spans(); - spans.push(Span::styled( - " ", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled("running...", Style::default().dim())); - let lines: Vec> = vec![ - Line::from(spans), + Line::from("Running command"), Line::from(format!("$ {}", self.command)), Line::from(""), ]; @@ -796,16 +711,11 @@ impl DynamicHeightWidgetRef for &ActiveExecCommandView { } impl WidgetRef for &ActiveExecCommandView { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let mut spans = self.shimmering_header_spans(); - spans.push(Span::styled( - " ", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled("running...", Style::default().dim())); + // Schedule a one-shot next frame to continue the shimmer. + self._app_event_tx + .send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); let lines: Vec> = vec![ - Line::from(spans), + Line::from(shimmer_spans("Running command")), Line::from(format!("$ {}", self.command)), Line::from(""), ]; @@ -815,16 +725,6 @@ impl WidgetRef for &ActiveExecCommandView { } } -fn color_for_level(level: u8) -> Color { - if level < 128 { - Color::DarkGray - } else if level < 192 { - Color::Gray - } else { - Color::White - } -} - fn create_diff_summary(changes: HashMap) -> Vec { // Build a concise, human‑readable summary list similar to the // `git status` short format so the user can reason about the diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0ec9be6153..8357f85e02 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -34,6 +34,7 @@ mod history_cell; mod insert_history; mod log_layer; mod markdown; +mod shimmer_text; mod slash_command; mod status_indicator_widget; mod text_block; diff --git a/codex-rs/tui/src/shimmer_text.rs b/codex-rs/tui/src/shimmer_text.rs new file mode 100644 index 0000000000..1f800282d6 --- /dev/null +++ b/codex-rs/tui/src/shimmer_text.rs @@ -0,0 +1,97 @@ +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Span; + +static PROCESS_START: OnceLock = OnceLock::new(); + +/// Ensure the process start time is initialized. Call early in app startup +/// so all animations key off a common origin. +pub(crate) fn init_process_start() { + let _ = PROCESS_START.set(Instant::now()); +} + +fn elapsed_since_start() -> Duration { + let start = PROCESS_START.get_or_init(Instant::now); + start.elapsed() +} + +/// Compute grayscale shimmer spans for the provided text based on elapsed +/// time since process start. Uses a cosine falloff across a small band to +/// achieve a smooth highlight that sweeps across the text. +pub(crate) fn shimmer_spans(text: &str) -> Vec> { + let header_chars: Vec = text.chars().collect(); + + // Synchronize the shimmer so that all instances start at the beginning + // and reach the end at the same time, regardless of length. We achieve + // this by mapping elapsed time into a global sweep fraction in [0, 1), + // then scaling that fraction across the character indices of this text. + // The bright band width (in characters) remains constant. + let len = header_chars.len(); + if len == 0 { + return Vec::new(); + } + + // Width of the bright band (in characters). + let band_half_width = (len as f32) / 4.0; + + // Use character-based padding: pretend the string is longer by + // `PADDING * 2` characters and move at a constant velocity over time. + // We compute the cycle duration in time (including pre/post time derived + // from character padding at constant velocity) and wrap using time modulo + // rather than modulo on character distance. + const SWEEP_SECONDS: f32 = 1.5; // time to traverse the visible text + let PADDING: f32 = band_half_width; + let elapsed = elapsed_since_start().as_secs_f32(); + let pos = (elapsed % SWEEP_SECONDS) / SWEEP_SECONDS * (len as f32 + PADDING * 2.0) - PADDING; + + let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false); + + let mut header_spans: Vec> = Vec::with_capacity(header_chars.len()); + for (i, ch) in header_chars.iter().enumerate() { + let i_pos = i as f32; + let dist = (i_pos - pos).abs(); + + let t = if dist <= band_half_width { + let x = std::f32::consts::PI * (dist / band_half_width); + 0.5 * (1.0 + x.cos()) + } else { + 0.0 + }; + + let brightness = 0.4 + 0.6 * t; + let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; + let style = if has_true_color { + Style::default() + .fg(Color::Rgb(level, level, level)) + .add_modifier(Modifier::BOLD) + } else { + // Bold makes dark gray and gray look the same, so don't use it + // when true color is not supported. + Style::default().fg(color_for_level(level)) + }; + + header_spans.push(Span::styled(ch.to_string(), style)); + } + + header_spans +} + +// + +/// Utility used for 16-color terminals to approximate grayscale. +pub(crate) fn color_for_level(level: u8) -> Color { + if level < 128 { + Color::DarkGray + } else if level < 192 { + Color::Gray + } else { + Color::White + } +} diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index aa18ac6fa5..bc93377da1 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -1,11 +1,6 @@ //! 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::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; -use std::thread; use std::time::Duration; use ratatui::buffer::Buffer; @@ -26,6 +21,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::shimmer_text::shimmer_spans; use codex_ansi_escape::ansi_escape_line; @@ -33,42 +29,15 @@ pub(crate) struct StatusIndicatorWidget { /// Latest text to display (truncated to the available width at render /// time). text: String, - - frame_idx: Arc, - running: Arc, - // Keep one sender alive to prevent the channel from closing while the - // animation thread is still running. The field itself is currently not - // accessed anywhere, therefore the leading underscore silences the - // `dead_code` warning without affecting behavior. + // Keep one sender alive for scheduling frames. _app_event_tx: AppEventSender, } impl StatusIndicatorWidget { /// Create a new status indicator and start the animation timer. pub(crate) fn new(app_event_tx: AppEventSender) -> Self { - let frame_idx = Arc::new(AtomicUsize::new(0)); - let running = Arc::new(AtomicBool::new(true)); - - // Animation thread. - { - let frame_idx_clone = Arc::clone(&frame_idx); - let running_clone = Arc::clone(&running); - let app_event_tx_clone = app_event_tx.clone(); - thread::spawn(move || { - let mut counter = 0usize; - while running_clone.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(100)); - counter = counter.wrapping_add(1); - frame_idx_clone.store(counter, Ordering::Relaxed); - app_event_tx_clone.send(AppEvent::RequestRedraw); - } - }); - } - Self { text: String::from("waiting for logs…"), - frame_idx, - running, _app_event_tx: app_event_tx, } } @@ -83,63 +52,19 @@ impl StatusIndicatorWidget { } } -impl Drop for StatusIndicatorWidget { - fn drop(&mut self) { - use std::sync::atomic::Ordering; - self.running.store(false, Ordering::Relaxed); - } -} - impl WidgetRef for StatusIndicatorWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Schedule the next animation frame. + self._app_event_tx + .send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); + let widget_style = Style::default(); let block = Block::default() .padding(Padding::new(1, 0, 0, 0)) .borders(Borders::LEFT) .border_type(BorderType::QuadrantOutside) .border_style(widget_style.dim()); - let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed); - let header_text = "Working"; - let header_chars: Vec = header_text.chars().collect(); - - let padding = 4usize; // virtual padding around the word for smoother loop - let period = header_chars.len() + padding * 2; - let pos = idx % period; - - let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false); - - // Width of the bright band (in characters). - let band_half_width = 2.0; - - let mut header_spans: Vec> = Vec::new(); - for (i, ch) in header_chars.iter().enumerate() { - let i_pos = i as isize + padding as isize; - let pos = pos as isize; - let dist = (i_pos - pos).abs() as f32; - - let t = if dist <= band_half_width { - let x = std::f32::consts::PI * (dist / band_half_width); - 0.5 * (1.0 + x.cos()) - } else { - 0.0 - }; - - let brightness = 0.4 + 0.6 * t; - let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; - let style = if has_true_color { - Style::default() - .fg(Color::Rgb(level, level, level)) - .add_modifier(Modifier::BOLD) - } else { - // Bold makes dark gray and gray look the same, so don't use it - // when true color is not supported. - Style::default().fg(color_for_level(level)) - }; - - header_spans.push(Span::styled(ch.to_string(), style)); - } + let mut header_spans: Vec> = shimmer_spans("Working"); header_spans.push(Span::styled( " ", @@ -194,13 +119,3 @@ impl WidgetRef for StatusIndicatorWidget { paragraph.render_ref(area, buf); } } - -fn color_for_level(level: u8) -> Color { - if level < 128 { - Color::DarkGray - } else if level < 192 { - Color::Gray - } else { - Color::White - } -}