This commit is contained in:
Jeremy Rose
2025-08-04 15:42:03 -07:00
parent d448975aae
commit 1f538dcb07
6 changed files with 181 additions and 233 deletions

View File

@@ -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<AtomicBool>,
pending_history_lines: Vec<Line<'static>>,
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
@@ -65,6 +60,10 @@ pub(crate) struct App<'a> {
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
}
/// 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::<Instant>();
{
let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || {
use std::sync::mpsc::RecvTimeoutError;
let mut next_deadline: Option<Instant> = 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))??;

View File

@@ -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.

View File

@@ -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<AtomicUsize>,
running: Arc<AtomicBool>,
_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<Span<'static>> {
let header_text = "command";
let header_chars: Vec<char> = 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<Span<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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<PathBuf, FileChange>) -> Vec<String> {
// Build a concise, humanreadable summary list similar to the
// `git status` short format so the user can reason about the

View File

@@ -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;

View File

@@ -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<Instant> = 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<Span<'static>> {
let header_chars: Vec<char> = 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<Span<'static>> = 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
}
}

View File

@@ -1,11 +1,6 @@
//! A live status indicator that shows the *latest* log line emitted by the
//! application while the agent is processing a longrunning 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<AtomicUsize>,
running: Arc<AtomicBool>,
// 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<char> = 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<Span<'static>> = 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<Span<'static>> = 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
}
}