From b9e443f0c4ec8b86cbd05bec61031ec12823a5e7 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 8 Jan 2026 00:02:08 -0800 Subject: [PATCH] shimmer --- codex-rs/core/src/features.rs | 8 + codex-rs/tui/Cargo.toml | 2 - codex-rs/tui/src/bottom_pane/mod.rs | 13 ++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/shimmer_text.rs | 2 +- codex-rs/tui/src/status_indicator_shimmer.rs | 154 +++++++++---------- codex-rs/tui/src/status_indicator_widget.rs | 17 +- 9 files changed, 108 insertions(+), 92 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 7ef74cfb7a..d18a3dfa57 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -87,6 +87,8 @@ pub enum Feature { ShellSnapshot, /// Experimental TUI v2 (viewport) implementation. Tui2, + /// Experimental entertainment mode for the status shimmer. + Entertainment, /// Enforce UTF8 output in Powershell. PowershellUtf8, } @@ -380,4 +382,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Entertainment, + key: "entertainment", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 287640de58..3248c7377c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -17,8 +17,6 @@ path = "src/lib.rs" vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] -# Enable entertainment-only shimmer behavior in the status indicator. -entertainment = [] [lints] workspace = true diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 329d8ec805..ac2d82c360 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -78,6 +78,7 @@ pub(crate) struct BottomPane { ctrl_c_quit_hint: bool, esc_backtrack_hint: bool, animations_enabled: bool, + entertainment_enabled: bool, /// Inline status indicator shown above the composer while a task is running. status: Option, @@ -97,6 +98,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) entertainment_enabled: bool, pub(crate) skills: Option>, } @@ -110,6 +112,7 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + entertainment_enabled, skills, } = params; let mut composer = ChatComposer::new( @@ -134,6 +137,7 @@ impl BottomPane { queued_user_messages: QueuedUserMessages::new(), esc_backtrack_hint: false, animations_enabled, + entertainment_enabled, context_window_percent: None, context_window_used_tokens: None, } @@ -353,6 +357,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.entertainment_enabled, )); } if let Some(status) = self.status.as_mut() { @@ -379,6 +384,7 @@ impl BottomPane { self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, + self.entertainment_enabled, )); self.request_redraw(); } @@ -636,6 +642,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); pane.push_approval_request(exec_request(), &features); @@ -659,6 +666,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -693,6 +701,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -760,6 +769,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -787,6 +797,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -818,6 +829,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); @@ -846,6 +858,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + entertainment_enabled: false, skills: Some(Vec::new()), }); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 45dcafd853..8d5e07b137 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1448,6 +1448,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + entertainment_enabled: config.features.enabled(Feature::Entertainment), skills: None, }), active_cell: None, @@ -1535,6 +1536,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + entertainment_enabled: config.features.enabled(Feature::Entertainment), skills: None, }), active_cell: None, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 735a07ac73..1b28288437 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -363,6 +363,7 @@ async fn make_chatwidget_manual( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + entertainment_enabled: cfg.features.enabled(Feature::Entertainment), skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 82ee764044..3fa41399c3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -68,7 +68,6 @@ mod resume_picker; mod selection_list; mod session_log; mod shimmer; -#[cfg(feature = "entertainment")] mod shimmer_text; mod slash_command; mod status; diff --git a/codex-rs/tui/src/shimmer_text.rs b/codex-rs/tui/src/shimmer_text.rs index 1bc839d20c..0d39df80ae 100644 --- a/codex-rs/tui/src/shimmer_text.rs +++ b/codex-rs/tui/src/shimmer_text.rs @@ -216,7 +216,7 @@ impl ShimmerText { } pub(crate) fn next_interval(&mut self, base: Duration) -> Duration { - let multiplier = self.rng.random_range(0.5..=1.25); + let multiplier = self.rng.random_range(0.4..=1.0); Duration::from_secs_f64(base.as_secs_f64() * multiplier) } diff --git a/codex-rs/tui/src/status_indicator_shimmer.rs b/codex-rs/tui/src/status_indicator_shimmer.rs index 9464c2c7e0..d1793055aa 100644 --- a/codex-rs/tui/src/status_indicator_shimmer.rs +++ b/codex-rs/tui/src/status_indicator_shimmer.rs @@ -1,16 +1,10 @@ -#[cfg(feature = "entertainment")] +use std::cell::Cell; +use std::cell::RefCell; use std::time::Duration; use std::time::Instant; -#[cfg(feature = "entertainment")] -use std::cell::Cell; -#[cfg(feature = "entertainment")] -use std::cell::RefCell; - -#[cfg(feature = "entertainment")] use crate::shimmer_text::ShimmerText; -#[cfg(feature = "entertainment")] const SHIMMER_TEXT_INTERVAL: Duration = Duration::from_secs(1); pub(crate) struct RenderHeader { @@ -18,10 +12,14 @@ pub(crate) struct RenderHeader { pub(crate) text: String, } -#[cfg(feature = "entertainment")] pub(crate) struct StatusShimmer { + entertainment_enabled: bool, header: String, - use_shimmer_text: bool, + entertainment: Option, +} + +struct EntertainmentState { + use_shimmer_text: Cell, shimmer_text: RefCell, shimmer_face_cache: RefCell, shimmer_text_cache: RefCell, @@ -29,111 +27,103 @@ pub(crate) struct StatusShimmer { shimmer_interval: Cell, } -#[cfg(feature = "entertainment")] impl StatusShimmer { - pub(crate) fn new(now: Instant) -> Self { - let mut shimmer_text = ShimmerText::new(); - let shimmer_step = shimmer_text.get_next(); - let shimmer_interval = shimmer_text.next_interval(SHIMMER_TEXT_INTERVAL); - Self { - header: shimmer_step.text.clone(), - use_shimmer_text: true, - shimmer_text: RefCell::new(shimmer_text), - shimmer_face_cache: RefCell::new(shimmer_step.face), - shimmer_text_cache: RefCell::new(shimmer_step.text), - last_shimmer_update: Cell::new(now), - shimmer_interval: Cell::new(shimmer_interval), + pub(crate) fn new(now: Instant, entertainment_enabled: bool) -> Self { + if entertainment_enabled { + let mut shimmer_text = ShimmerText::new(); + let shimmer_step = shimmer_text.get_next(); + let shimmer_interval = shimmer_text.next_interval(SHIMMER_TEXT_INTERVAL); + let entertainment = EntertainmentState { + use_shimmer_text: Cell::new(true), + shimmer_text: RefCell::new(shimmer_text), + shimmer_face_cache: RefCell::new(shimmer_step.face), + shimmer_text_cache: RefCell::new(shimmer_step.text.clone()), + last_shimmer_update: Cell::new(now), + shimmer_interval: Cell::new(shimmer_interval), + }; + Self { + entertainment_enabled, + header: shimmer_step.text, + entertainment: Some(entertainment), + } + } else { + Self { + entertainment_enabled, + header: String::from("Working"), + entertainment: None, + } } } pub(crate) fn update_header(&mut self, header: String) { - let was_shimmer = self.use_shimmer_text; - let use_shimmer = self.shimmer_text.borrow().is_default_label(&header); - self.use_shimmer_text = use_shimmer; + self.header = header.clone(); + if !self.entertainment_enabled { + return; + } + let Some(state) = self.entertainment.as_ref() else { + return; + }; + let was_shimmer = state.use_shimmer_text.get(); + let use_shimmer = state.shimmer_text.borrow().is_default_label(&header); + state.use_shimmer_text.set(use_shimmer); if use_shimmer { - self.header = header.clone(); if !was_shimmer { - let next = self.shimmer_text.borrow_mut().reset_and_get_next(); - self.set_shimmer_step(next); - let next_interval = self + let next = state.shimmer_text.borrow_mut().reset_and_get_next(); + self.set_shimmer_step(state, next); + let next_interval = state .shimmer_text .borrow_mut() .next_interval(SHIMMER_TEXT_INTERVAL); - self.shimmer_interval.set(next_interval); + state.shimmer_interval.set(next_interval); } - self.last_shimmer_update.set(Instant::now()); - } else { - self.header = header; + state.last_shimmer_update.set(Instant::now()); } } #[cfg(test)] pub(crate) fn header_for_test(&self) -> String { - if self.use_shimmer_text { - self.shimmer_text_cache.borrow().clone() - } else { - self.header.clone() + if let Some(state) = self.entertainment.as_ref() + && state.use_shimmer_text.get() + { + return state.shimmer_text_cache.borrow().clone(); } + self.header.clone() } pub(crate) fn render_header(&self, now: Instant) -> RenderHeader { - if !self.use_shimmer_text { + let Some(state) = self.entertainment.as_ref() else { return RenderHeader { - face: Some(self.shimmer_face_cache.borrow().clone()), + face: None, + text: self.header.clone(), + }; + }; + if !state.use_shimmer_text.get() { + return RenderHeader { + face: Some(state.shimmer_face_cache.borrow().clone()), text: self.header.clone(), }; } - let elapsed = now.saturating_duration_since(self.last_shimmer_update.get()); - if elapsed >= self.shimmer_interval.get() { - let next = self.shimmer_text.borrow_mut().get_next(); - self.set_shimmer_step(next); - self.last_shimmer_update.set(now); - let next_interval = self + let elapsed = now.saturating_duration_since(state.last_shimmer_update.get()); + if elapsed >= state.shimmer_interval.get() { + let next = state.shimmer_text.borrow_mut().get_next(); + self.set_shimmer_step(state, next); + state.last_shimmer_update.set(now); + let next_interval = state .shimmer_text .borrow_mut() .next_interval(SHIMMER_TEXT_INTERVAL); - self.shimmer_interval.set(next_interval); + state.shimmer_interval.set(next_interval); } RenderHeader { - face: Some(self.shimmer_face_cache.borrow().clone()), - text: self.shimmer_text_cache.borrow().clone(), + face: Some(state.shimmer_face_cache.borrow().clone()), + text: state.shimmer_text_cache.borrow().clone(), } } - fn set_shimmer_step(&self, step: crate::shimmer_text::ShimmerStep) { - *self.shimmer_face_cache.borrow_mut() = step.face; - *self.shimmer_text_cache.borrow_mut() = step.text; - } -} - -#[cfg(not(feature = "entertainment"))] -pub(crate) struct StatusShimmer { - header: String, -} - -#[cfg(not(feature = "entertainment"))] -impl StatusShimmer { - pub(crate) fn new(_now: Instant) -> Self { - Self { - header: String::from("Working"), - } - } - - pub(crate) fn update_header(&mut self, header: String) { - self.header = header; - } - - #[cfg(test)] - pub(crate) fn header_for_test(&self) -> String { - self.header.clone() - } - - pub(crate) fn render_header(&self, _now: Instant) -> RenderHeader { - RenderHeader { - face: None, - text: self.header.clone(), - } + fn set_shimmer_step(&self, state: &EntertainmentState, step: crate::shimmer_text::ShimmerStep) { + *state.shimmer_face_cache.borrow_mut() = step.face; + *state.shimmer_text_cache.borrow_mut() = step.text; } } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 63548690e7..cbe283849d 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -67,10 +67,11 @@ impl StatusIndicatorWidget { app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, + entertainment_enabled: bool, ) -> Self { let now = Instant::now(); Self { - shimmer: StatusShimmer::new(now), + shimmer: StatusShimmer::new(now, entertainment_enabled), details: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, @@ -280,7 +281,8 @@ mod tests { fn renders_with_working_header() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); @@ -294,7 +296,8 @@ mod tests { fn renders_truncated() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); @@ -308,7 +311,8 @@ mod tests { fn renders_wrapped_details_panama_two_lines() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); + let mut w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false, false); w.update_details(Some("A man a plan a canal panama".to_string())); w.set_interrupt_hint_visible(false); @@ -330,7 +334,7 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut widget = - StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); let baseline = Instant::now(); widget.last_resume_at = baseline; @@ -351,7 +355,8 @@ mod tests { fn details_overflow_adds_ellipsis() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + let mut w = + StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true, false); w.update_details(Some("abcd abcd abcd abcd".to_string())); let lines = w.wrapped_details_lines(6);