From d448975aae0c4c50276e8782954cd511693d2000 Mon Sep 17 00:00:00 2001 From: Jeremy Rose Date: Mon, 4 Aug 2025 14:26:11 -0700 Subject: [PATCH] wip --- codex-rs/tui/src/chatwidget.rs | 5 +- codex-rs/tui/src/history_cell.rs | 145 ++++++++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0e558c6db..9d5a13136d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -380,7 +380,10 @@ impl ChatWidget<'_> { cwd: cwd.clone(), }, ); - self.active_history_cell = Some(HistoryCell::new_active_exec_command(command)); + self.active_history_cell = Some(HistoryCell::new_active_exec_command( + command, + self.app_event_tx.clone(), + )); } EventMsg::ExecCommandOutputDelta(_) => { // TODO diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5f23fab46e..3f053d8426 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -31,9 +31,16 @@ 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; + pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, @@ -78,7 +85,7 @@ pub(crate) enum HistoryCell { AgentReasoning { view: TextBlock }, /// An exec tool call that has not finished yet. - ActiveExecCommand { command: String }, + ActiveExecCommand { view: ActiveExecCommandView }, /// Completed exec tool call. CompletedExecCommand { view: TextBlock }, @@ -148,10 +155,10 @@ impl HistoryCell { | HistoryCell::ActiveMcpToolCall { view, .. } => { view.lines.iter().map(line_to_static).collect() } - HistoryCell::ActiveExecCommand { command, .. } => { + HistoryCell::ActiveExecCommand { view, .. } => { let lines: Vec> = vec![ Line::from(vec!["command".magenta(), " running...".dim()]), - Line::from(format!("$ {command}")), + Line::from(format!("$ {}", view.command)), Line::from(""), ]; lines.iter().map(line_to_static).collect() @@ -178,9 +185,7 @@ impl HistoryCell { | HistoryCell::PendingPatch { view } | HistoryCell::PlanUpdate { view } | HistoryCell::ActiveMcpToolCall { view, .. } => Box::new(view), - HistoryCell::ActiveExecCommand { command, .. } => Box::new(ActiveExecCommandView { - command: command.clone(), - }), + HistoryCell::ActiveExecCommand { view, .. } => Box::new(view), HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => { panic!("view() called on image output cell") } @@ -292,11 +297,14 @@ impl HistoryCell { } } - pub(crate) fn new_active_exec_command(command: Vec) -> Self { + pub(crate) fn new_active_exec_command( + command: Vec, + app_event_tx: AppEventSender, + ) -> Self { let command_escaped = strip_bash_lc_and_escape(&command); HistoryCell::ActiveExecCommand { - command: command_escaped, + view: ActiveExecCommandView::new(command_escaped, app_event_tx), } } @@ -676,13 +684,106 @@ impl WidgetRef for &HistoryCell { } } -struct ActiveExecCommandView { +pub(crate) struct ActiveExecCommandView { command: String, + frame_idx: Arc, + running: Arc, } -impl DynamicHeightWidgetRef for ActiveExecCommandView { + +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, + } + } + + 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(vec!["command".yellow(), " running...".dim()]), + Line::from(spans), Line::from(format!("$ {}", self.command)), Line::from(""), ]; @@ -693,10 +794,18 @@ impl DynamicHeightWidgetRef for ActiveExecCommandView { .unwrap_or(0) } } -impl WidgetRef 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())); let lines: Vec> = vec![ - Line::from(vec!["command".yellow(), " running...".dim()]), + Line::from(spans), Line::from(format!("$ {}", self.command)), Line::from(""), ]; @@ -706,6 +815,16 @@ 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