diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0a650511..350773a3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -988,7 +988,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "throbber-widgets-tui", "tokio", "tokio-stream", "tracing", @@ -5843,16 +5842,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "throbber-widgets-tui" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d" -dependencies = [ - "rand 0.8.5", - "ratatui", -] - [[package]] name = "tiff" version = "0.10.3" diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index d0cee3ff..13d72527 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -1,7 +1,7 @@ [package] +edition = "2024" name = "codex-cloud-tasks" version = { workspace = true } -edition = "2024" [lib] name = "codex_cloud_tasks" @@ -12,25 +12,27 @@ workspace = true [dependencies] anyhow = "1" -clap = { version = "4", features = ["derive"] } -codex-common = { path = "../common", features = ["cli"] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] } -ratatui = { version = "0.29.0" } -crossterm = { version = "0.28.1", features = ["event-stream"] } -tokio-stream = "0.1.17" -chrono = { version = "0.4", features = ["serde"] } -codex-login = { path = "../login" } -codex-core = { path = "../core" } -throbber-widgets-tui = "0.8.0" base64 = "0.22" -serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [ + "mock", + "online", +] } +codex-common = { path = "../common", features = ["cli"] } +codex-core = { path = "../core" } +codex-login = { path = "../login" } +codex-tui = { path = "../tui" } +crossterm = { version = "0.28.1", features = ["event-stream"] } +ratatui = { version = "0.29.0" } reqwest = { version = "0.12", features = ["json"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio-stream = "0.1.17" +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } unicode-width = "0.1" -codex-tui = { path = "../tui" } [dev-dependencies] async-trait = "0.1" diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index adf17419..612c5f6b 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use std::time::Instant; // Environment filter data models for the TUI #[derive(Clone, Debug, Default)] @@ -42,15 +43,13 @@ use crate::scrollable_diff::ScrollableDiff; use codex_cloud_tasks_client::CloudBackend; use codex_cloud_tasks_client::TaskId; use codex_cloud_tasks_client::TaskSummary; -use throbber_widgets_tui::ThrobberState; - #[derive(Default)] pub struct App { pub tasks: Vec, pub selected: usize, pub status: String, pub diff_overlay: Option, - pub throbber: ThrobberState, + pub spinner_start: Option, pub refresh_inflight: bool, pub details_inflight: bool, // Environment filter state @@ -82,7 +81,7 @@ impl App { selected: 0, status: "Press r to refresh".to_string(), diff_overlay: None, - throbber: ThrobberState::default(), + spinner_start: None, refresh_inflight: false, details_inflight: false, env_filter: None, diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index da2d4eb9..69490e1c 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -400,16 +400,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay()); } } - // Advance throbber only while loading. + // Keep spinner pulsing only while loading. if app.refresh_inflight || app.details_inflight || app.env_loading || app.apply_preflight_inflight || app.apply_inflight { - app.throbber.calc_next(); + if app.spinner_start.is_none() { + app.spinner_start = Some(Instant::now()); + } needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); + let _ = frame_tx.send(Instant::now() + Duration::from_millis(600)); + } else { + app.spinner_start = None; } render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?; } diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index fe91b0ce..81353a03 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -16,6 +16,7 @@ use ratatui::widgets::ListState; use ratatui::widgets::Padding; use ratatui::widgets::Paragraph; use std::sync::OnceLock; +use std::time::Instant; use crate::app::App; use crate::app::AttemptView; @@ -229,7 +230,7 @@ fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) { // In-box spinner during initial/refresh loads if app.refresh_inflight { - draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…"); + draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…"); } } @@ -291,7 +292,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { || app.apply_preflight_inflight || app.apply_inflight { - draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…"); + draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…"); } else { frame.render_widget(Clear, top[1]); } @@ -449,7 +450,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) { .map(|o| o.sd.wrapped_lines().is_empty()) .unwrap_or(true); if app.details_inflight && raw_empty { - draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…"); + draw_centered_spinner( + frame, + content_area, + &mut app.spinner_start, + "Loading details…", + ); } else { let scroll = app .diff_overlay @@ -494,11 +500,11 @@ pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) { frame.render_widget(header, rows[0]); // Body: spinner while preflight/apply runs; otherwise show result message and path lists if app.apply_preflight_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…"); } else if app.apply_inflight { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…"); } else if m.result_message.is_none() { - draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…"); + draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…"); } else if let Some(msg) = &m.result_message { let mut body_lines: Vec = Vec::new(); let first = match m.result_level { @@ -859,29 +865,29 @@ fn format_relative_time(ts: chrono::DateTime) -> String { fn draw_inline_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - use ratatui::style::Style; - use throbber_widgets_tui::BRAILLE_EIGHT; - use throbber_widgets_tui::Throbber; - use throbber_widgets_tui::WhichUse; - let w = Throbber::default() - .label(label) - .style(Style::default().cyan()) - .throbber_style(Style::default().magenta().bold()) - .throbber_set(BRAILLE_EIGHT) - .use_type(WhichUse::Spin); - frame.render_stateful_widget(w, area, state); + use ratatui::widgets::Paragraph; + let start = spinner_start.get_or_insert_with(Instant::now); + let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2); + let dot = if blink_on { + "• ".into() + } else { + "◦ ".dim() + }; + let label = label.cyan(); + let line = Line::from(vec![dot, label]); + frame.render_widget(Paragraph::new(line), area); } fn draw_centered_spinner( frame: &mut Frame, area: Rect, - state: &mut throbber_widgets_tui::ThrobberState, + spinner_start: &mut Option, label: &str, ) { - // Center a 1xN throbber within the given rect + // Center a 1xN spinner within the given rect let rows = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -898,7 +904,7 @@ fn draw_centered_spinner( Constraint::Percentage(50), ]) .split(rows[1]); - draw_inline_spinner(frame, cols[1], state, label); + draw_inline_spinner(frame, cols[1], spinner_start, label); } // Styling helpers for diff rendering live inline where used. @@ -918,7 +924,12 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { let content = overlay_content(inner); if app.env_loading { - draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…"); + draw_centered_spinner( + frame, + content, + &mut app.spinner_start, + "Loading environments…", + ); return; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap index 3b338006..588a9503 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: blob1 --- -⠋ Exploring +• Exploring └ List ls -la diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap index ddcd484b..2ce41709 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -2,6 +2,6 @@ source: tui/src/chatwidget/tests.rs expression: blob3 --- -⠋ Exploring +• Exploring └ List ls -la Read foo.txt diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 19232c86..9c1231f0 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -116,12 +116,10 @@ pub(crate) fn output_lines( } pub(crate) fn spinner(start_time: Option) -> Span<'static> { - const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let idx = start_time - .map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len()) - .unwrap_or(0); - let ch = FRAMES[idx]; - ch.to_string().into() + let blink_on = start_time + .map(|st| ((st.elapsed().as_millis() / 600) % 2) == 0) + .unwrap_or(false); + if blink_on { "•".into() } else { "◦".dim() } } impl HistoryCell for ExecCell { diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap index 2c1067b2..7360fd13 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap @@ -3,4 +3,4 @@ source: tui/src/history_cell.rs assertion_line: 1740 expression: rendered --- -⠋ Calling search.find_docs({"query":"ratatui styling","limit":3}) +• Calling search.find_docs({"query":"ratatui styling","limit":3}) diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 2660aa1b..e60204cd 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -134,12 +134,16 @@ impl StatusIndicatorWidget { self.frame_requester.schedule_frame(); } - fn elapsed_seconds_at(&self, now: Instant) -> u64 { + 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.as_secs() + elapsed + } + + fn elapsed_seconds_at(&self, now: Instant) -> u64 { + self.elapsed_duration_at(now).as_secs() } pub fn elapsed_seconds(&self) -> u64 { @@ -156,11 +160,18 @@ impl WidgetRef for StatusIndicatorWidget { // Schedule next animation frame. self.frame_requester .schedule_frame_in(Duration::from_millis(32)); - let elapsed = self.elapsed_seconds(); - let pretty_elapsed = fmt_elapsed_compact(elapsed); + let now = Instant::now(); + let elapsed_duration = self.elapsed_duration_at(now); + let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); + let blink_on = (elapsed_duration.as_millis() / 600).is_multiple_of(2); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. - let mut spans = vec!["• ".dim()]; + let mut spans = Vec::with_capacity(5); + if blink_on { + spans.push("• ".into()); + } else { + spans.push("◦ ".dim()); + } spans.extend(shimmer_spans(&self.header)); spans.extend(vec![ " ".into(),