Skip to content

Commit d3e1beb

Browse files
add pulsing dot loading state (#4736)
## Description Changes default CLI spinner to pulsing dot https://github.com/user-attachments/assets/b81225d6-6655-4ead-8cb1-d6568a603d5b ## Tests Passes CI --------- Co-authored-by: Fouad Matin <[email protected]>
1 parent c264ae6 commit d3e1beb

10 files changed

+82
-68
lines changed

codex-rs/Cargo.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/cloud-tasks/Cargo.toml

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
2+
edition = "2024"
23
name = "codex-cloud-tasks"
34
version = { workspace = true }
4-
edition = "2024"
55

66
[lib]
77
name = "codex_cloud_tasks"
@@ -12,25 +12,27 @@ workspace = true
1212

1313
[dependencies]
1414
anyhow = "1"
15+
base64 = "0.22"
16+
chrono = { version = "0.4", features = ["serde"] }
1517
clap = { version = "4", features = ["derive"] }
18+
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [
19+
"mock",
20+
"online",
21+
] }
1622
codex-common = { path = "../common", features = ["cli"] }
17-
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
18-
tracing = { version = "0.1.41", features = ["log"] }
19-
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
20-
codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock", "online"] }
21-
ratatui = { version = "0.29.0" }
22-
crossterm = { version = "0.28.1", features = ["event-stream"] }
23-
tokio-stream = "0.1.17"
24-
chrono = { version = "0.4", features = ["serde"] }
25-
codex-login = { path = "../login" }
2623
codex-core = { path = "../core" }
27-
throbber-widgets-tui = "0.8.0"
28-
base64 = "0.22"
29-
serde_json = "1"
24+
codex-login = { path = "../login" }
25+
codex-tui = { path = "../tui" }
26+
crossterm = { version = "0.28.1", features = ["event-stream"] }
27+
ratatui = { version = "0.29.0" }
3028
reqwest = { version = "0.12", features = ["json"] }
3129
serde = { version = "1", features = ["derive"] }
30+
serde_json = "1"
31+
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
32+
tokio-stream = "0.1.17"
33+
tracing = { version = "0.1.41", features = ["log"] }
34+
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
3235
unicode-width = "0.1"
33-
codex-tui = { path = "../tui" }
3436

3537
[dev-dependencies]
3638
async-trait = "0.1"

codex-rs/cloud-tasks/src/app.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::time::Duration;
2+
use std::time::Instant;
23

34
// Environment filter data models for the TUI
45
#[derive(Clone, Debug, Default)]
@@ -42,15 +43,13 @@ use crate::scrollable_diff::ScrollableDiff;
4243
use codex_cloud_tasks_client::CloudBackend;
4344
use codex_cloud_tasks_client::TaskId;
4445
use codex_cloud_tasks_client::TaskSummary;
45-
use throbber_widgets_tui::ThrobberState;
46-
4746
#[derive(Default)]
4847
pub struct App {
4948
pub tasks: Vec<TaskSummary>,
5049
pub selected: usize,
5150
pub status: String,
5251
pub diff_overlay: Option<DiffOverlay>,
53-
pub throbber: ThrobberState,
52+
pub spinner_start: Option<Instant>,
5453
pub refresh_inflight: bool,
5554
pub details_inflight: bool,
5655
// Environment filter state
@@ -82,7 +81,7 @@ impl App {
8281
selected: 0,
8382
status: "Press r to refresh".to_string(),
8483
diff_overlay: None,
85-
throbber: ThrobberState::default(),
84+
spinner_start: None,
8685
refresh_inflight: false,
8786
details_inflight: false,
8887
env_filter: None,

codex-rs/cloud-tasks/src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,16 +400,20 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
400400
let _ = frame_tx.send(Instant::now() + codex_tui::ComposerInput::recommended_flush_delay());
401401
}
402402
}
403-
// Advance throbber only while loading.
403+
// Keep spinner pulsing only while loading.
404404
if app.refresh_inflight
405405
|| app.details_inflight
406406
|| app.env_loading
407407
|| app.apply_preflight_inflight
408408
|| app.apply_inflight
409409
{
410-
app.throbber.calc_next();
410+
if app.spinner_start.is_none() {
411+
app.spinner_start = Some(Instant::now());
412+
}
411413
needs_redraw = true;
412-
let _ = frame_tx.send(Instant::now() + Duration::from_millis(100));
414+
let _ = frame_tx.send(Instant::now() + Duration::from_millis(600));
415+
} else {
416+
app.spinner_start = None;
413417
}
414418
render_if_needed(&mut terminal, &mut app, &mut needs_redraw)?;
415419
}

codex-rs/cloud-tasks/src/ui.rs

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use ratatui::widgets::ListState;
1616
use ratatui::widgets::Padding;
1717
use ratatui::widgets::Paragraph;
1818
use std::sync::OnceLock;
19+
use std::time::Instant;
1920

2021
use crate::app::App;
2122
use crate::app::AttemptView;
@@ -229,7 +230,7 @@ fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) {
229230

230231
// In-box spinner during initial/refresh loads
231232
if app.refresh_inflight {
232-
draw_centered_spinner(frame, inner, &mut app.throbber, "Loading tasks…");
233+
draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…");
233234
}
234235
}
235236

@@ -291,7 +292,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
291292
|| app.apply_preflight_inflight
292293
|| app.apply_inflight
293294
{
294-
draw_inline_spinner(frame, top[1], &mut app.throbber, "Loading…");
295+
draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…");
295296
} else {
296297
frame.render_widget(Clear, top[1]);
297298
}
@@ -449,7 +450,12 @@ fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
449450
.map(|o| o.sd.wrapped_lines().is_empty())
450451
.unwrap_or(true);
451452
if app.details_inflight && raw_empty {
452-
draw_centered_spinner(frame, content_area, &mut app.throbber, "Loading details…");
453+
draw_centered_spinner(
454+
frame,
455+
content_area,
456+
&mut app.spinner_start,
457+
"Loading details…",
458+
);
453459
} else {
454460
let scroll = app
455461
.diff_overlay
@@ -494,11 +500,11 @@ pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) {
494500
frame.render_widget(header, rows[0]);
495501
// Body: spinner while preflight/apply runs; otherwise show result message and path lists
496502
if app.apply_preflight_inflight {
497-
draw_centered_spinner(frame, rows[1], &mut app.throbber, "Checking…");
503+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…");
498504
} else if app.apply_inflight {
499-
draw_centered_spinner(frame, rows[1], &mut app.throbber, "Applying…");
505+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…");
500506
} else if m.result_message.is_none() {
501-
draw_centered_spinner(frame, rows[1], &mut app.throbber, "Loading…");
507+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…");
502508
} else if let Some(msg) = &m.result_message {
503509
let mut body_lines: Vec<Line> = Vec::new();
504510
let first = match m.result_level {
@@ -859,29 +865,29 @@ fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
859865
fn draw_inline_spinner(
860866
frame: &mut Frame,
861867
area: Rect,
862-
state: &mut throbber_widgets_tui::ThrobberState,
868+
spinner_start: &mut Option<Instant>,
863869
label: &str,
864870
) {
865-
use ratatui::style::Style;
866-
use throbber_widgets_tui::BRAILLE_EIGHT;
867-
use throbber_widgets_tui::Throbber;
868-
use throbber_widgets_tui::WhichUse;
869-
let w = Throbber::default()
870-
.label(label)
871-
.style(Style::default().cyan())
872-
.throbber_style(Style::default().magenta().bold())
873-
.throbber_set(BRAILLE_EIGHT)
874-
.use_type(WhichUse::Spin);
875-
frame.render_stateful_widget(w, area, state);
871+
use ratatui::widgets::Paragraph;
872+
let start = spinner_start.get_or_insert_with(Instant::now);
873+
let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2);
874+
let dot = if blink_on {
875+
"• ".into()
876+
} else {
877+
"◦ ".dim()
878+
};
879+
let label = label.cyan();
880+
let line = Line::from(vec![dot, label]);
881+
frame.render_widget(Paragraph::new(line), area);
876882
}
877883

878884
fn draw_centered_spinner(
879885
frame: &mut Frame,
880886
area: Rect,
881-
state: &mut throbber_widgets_tui::ThrobberState,
887+
spinner_start: &mut Option<Instant>,
882888
label: &str,
883889
) {
884-
// Center a 1xN throbber within the given rect
890+
// Center a 1xN spinner within the given rect
885891
let rows = Layout::default()
886892
.direction(Direction::Vertical)
887893
.constraints([
@@ -898,7 +904,7 @@ fn draw_centered_spinner(
898904
Constraint::Percentage(50),
899905
])
900906
.split(rows[1]);
901-
draw_inline_spinner(frame, cols[1], state, label);
907+
draw_inline_spinner(frame, cols[1], spinner_start, label);
902908
}
903909

904910
// 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) {
918924
let content = overlay_content(inner);
919925

920926
if app.env_loading {
921-
draw_centered_spinner(frame, content, &mut app.throbber, "Loading environments…");
927+
draw_centered_spinner(
928+
frame,
929+
content,
930+
&mut app.spinner_start,
931+
"Loading environments…",
932+
);
922933
return;
923934
}
924935

codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
source: tui/src/chatwidget/tests.rs
33
expression: blob1
44
---
5-
Exploring
5+
Exploring
66
List ls -la

codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
source: tui/src/chatwidget/tests.rs
33
expression: blob3
44
---
5-
Exploring
5+
Exploring
66
List ls -la
77
Read foo.txt

codex-rs/tui/src/exec_cell/render.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,10 @@ pub(crate) fn output_lines(
116116
}
117117

118118
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
119-
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
120-
let idx = start_time
121-
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
122-
.unwrap_or(0);
123-
let ch = FRAMES[idx];
124-
ch.to_string().into()
119+
let blink_on = start_time
120+
.map(|st| ((st.elapsed().as_millis() / 600) % 2) == 0)
121+
.unwrap_or(false);
122+
if blink_on { "•".into() } else { "◦".dim() }
125123
}
126124

127125
impl HistoryCell for ExecCell {

codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ source: tui/src/history_cell.rs
33
assertion_line: 1740
44
expression: rendered
55
---
6-
Calling search.find_docs({"query":"ratatui styling","limit":3})
6+
Calling search.find_docs({"query":"ratatui styling","limit":3})

codex-rs/tui/src/status_indicator_widget.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,16 @@ impl StatusIndicatorWidget {
134134
self.frame_requester.schedule_frame();
135135
}
136136

137-
fn elapsed_seconds_at(&self, now: Instant) -> u64 {
137+
fn elapsed_duration_at(&self, now: Instant) -> Duration {
138138
let mut elapsed = self.elapsed_running;
139139
if !self.is_paused {
140140
elapsed += now.saturating_duration_since(self.last_resume_at);
141141
}
142-
elapsed.as_secs()
142+
elapsed
143+
}
144+
145+
fn elapsed_seconds_at(&self, now: Instant) -> u64 {
146+
self.elapsed_duration_at(now).as_secs()
143147
}
144148

145149
pub fn elapsed_seconds(&self) -> u64 {
@@ -156,11 +160,18 @@ impl WidgetRef for StatusIndicatorWidget {
156160
// Schedule next animation frame.
157161
self.frame_requester
158162
.schedule_frame_in(Duration::from_millis(32));
159-
let elapsed = self.elapsed_seconds();
160-
let pretty_elapsed = fmt_elapsed_compact(elapsed);
163+
let now = Instant::now();
164+
let elapsed_duration = self.elapsed_duration_at(now);
165+
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
166+
let blink_on = (elapsed_duration.as_millis() / 600).is_multiple_of(2);
161167

162168
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
163-
let mut spans = vec!["• ".dim()];
169+
let mut spans = Vec::with_capacity(5);
170+
if blink_on {
171+
spans.push("• ".into());
172+
} else {
173+
spans.push("◦ ".dim());
174+
}
164175
spans.extend(shimmer_spans(&self.header));
165176
spans.extend(vec![
166177
" ".into(),

0 commit comments

Comments
 (0)