diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 107717b98c..f7e0090fb6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -895,8 +895,6 @@ impl App { match &event { TuiEvent::FocusLost => { - self.chat_widget - .set_sparkle_terminal_focus(/*focused*/ false); let now = Instant::now(); let thread_id = self.current_displayed_thread_id(); @@ -1011,8 +1009,6 @@ impl App { fn render_chat_widget_frame(&mut self, tui: &mut tui::Tui, screen_size: Size) -> Result { self.sync_thread_title_progress(); - self.chat_widget - .set_sparkle_terminal_focus(tui.is_terminal_focused()); let dashboard_visible = self .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 54775ca6c6..47afa30386 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4,20 +4,11 @@ //! completed slash commands to atomic elements, and handles Enter submission/newlines. //! It also shows Luna Reserve's yellow prompt arrow and detects unbracketed paste bursts //! from raw key streams, particularly on Windows. -//! The live voice strip renders after effort ignition and before stars, which skip its text. +//! The live voice strip renders after effort ignition. //! //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! submit ordinary text without enabling shell mode. //! -//! # Astra Sparkle -//! -//! Selecting Astra animates stars in the untouched composer for 15 seconds from its first visible -//! frame, then fades them smoothly for one second. The deadline runs even without terminal focus. -//! Keys, paste, an existing draft, voice input, or a popup start a quick fade; offline Enter and -//! Tab do too. Placeholder and draft text, including spaces, stay unchanged. Mouse reporting stays -//! disabled for native selection and scrolling; hover or selection alone does not stop the stars. -//! Reselecting Astra starts a new flourish. -//! //! # Mention Menus //! //! By default, `@` lists plugins, filesystem entries, and skills. Skills are hidden when their @@ -325,7 +316,6 @@ mod inline_input; mod popup_state; mod reconnect; mod slash_input; -mod sparkle; mod vim_history; mod vim_search; @@ -541,7 +531,6 @@ pub(crate) struct ChatComposer { effort_animation_style: Option, effort_ignition: Option, voice_strip: Option, - astra_sparkle: Option, effort_status_line_transition: Option, effort_observed: bool, luna_reserve_active: bool, @@ -710,7 +699,6 @@ impl ChatComposer { effort_animation_style: None, effort_ignition: None, voice_strip: None, - astra_sparkle: None, effort_status_line_transition: None, effort_observed: false, luna_reserve_active: false, @@ -1230,7 +1218,6 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { - self.interact_with_astra_sparkle(); let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let pasted = sanitize_user_text(pasted.into()); if let Some(query) = self.draft.textarea.vim_query_mut() { @@ -2032,7 +2019,6 @@ impl ChatComposer { return (InputResult::None, false); } - self.interact_with_astra_sparkle(); if self.history_search.is_none() && !self.popups.active() && self.draft.textarea.wants_vim_search_key(key_event) @@ -5070,14 +5056,6 @@ impl ChatComposer { } drop(state); self.render_voice_strip(composer_rect, buf); - if self.astra_sparkle.is_some() { - self.render_sparkle( - composer_rect, - textarea_rect, - self.cursor_pos_with_textarea_right_reserve(area, textarea_right_reserve), - buf, - ); - } } } @@ -5093,6 +5071,10 @@ mod effort_tests; #[path = "chat_composer/embedded_input_tests.rs"] mod embedded_input_tests; +#[cfg(test)] +#[path = "chat_composer/snapshot_tests.rs"] +mod snapshot_tests; + #[cfg(test)] mod tests { use super::attachment_state::AttachedImage; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs index cf90c35a35..c0669203d5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -45,11 +45,6 @@ impl ChatComposer { } pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { - if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && !matches!(key.code, KeyCode::Null) - { - self.interact_with_astra_sparkle(); - } self.cancel_history_search(); self.attachments.clear_remote_image_selection(); self.popups.active = ActivePopup::None; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs new file mode 100644 index 0000000000..900f14a219 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshot_tests.rs @@ -0,0 +1,71 @@ +//! Snapshot coverage for draft text and live voice controls in the composer. + +use super::tests::new_test_composer; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +#[test] +fn draft_and_voice_composer_snapshots() { + use crate::bottom_pane::voice_strip::VoiceStripPhase; + use crate::bottom_pane::voice_strip::VoiceStripState; + use crate::tui::FrameRequester; + + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (230, 216, 255), + bg: (36, 27, 53), + }, + || { + for voice_active in [false, true] { + let (mut composer, _rx) = new_test_composer(); + if voice_active { + composer.set_voice_strip( + Some(VoiceStripState { + mute_hint: None, + phase: VoiceStripPhase::Active, + microphone_live: true, + microphone_muted: false, + microphone_history: vec![0, 37, 73, 110, 146, 255], + speaker_history: vec![255, 146, 110, 73, 37, 0], + activity: "listening", + animations: false, + }), + FrameRequester::test_dummy(), + ); + } else { + composer.set_text_content( + "Explore the night sky".to_string(), + Vec::new(), + Vec::new(), + ); + } + let width = 60; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + composer.desired_height(width), + ); + let mut buffer = Buffer::empty(area); + composer.render(area, &mut buffer); + let rows = buffer + .content + .chunks(usize::from(width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n"); + let name = if voice_active { + "voice_composer" + } else { + "draft_composer" + }; + insta::assert_snapshot!(name, rows); + } + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_current_composer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap similarity index 77% rename from codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_current_composer.snap rename to codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap index da0f1cb3c2..732958b8e8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_current_composer.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap @@ -1,6 +1,5 @@ --- -source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs -assertion_line: 48 +source: tui/src/bottom_pane/chat_composer/snapshot_tests.rs expression: rows --- diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_voice_composer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap similarity index 66% rename from codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_voice_composer.snap rename to codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap index 28deb77590..7b3a014825 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_voice_composer.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap @@ -1,7 +1,6 @@ --- -source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs -assertion_line: 361 -expression: "baseline.content.chunks(60).map(|row|\nrow.iter().map(ratatui::buffer::Cell::symbol).collect::()).collect::>().join(\"\\n\")" +source: tui/src/bottom_pane/chat_composer/snapshot_tests.rs +expression: rows --- voice ● listening /voice mute /voice stop diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_idle_timeout.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_idle_timeout.snap deleted file mode 100644 index dfcb3cf1fb..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_idle_timeout.snap +++ /dev/null @@ -1,34 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs -assertion_line: 260 -expression: "format!(\"before timeout\\n{}\\n\\nfading\\n{}\\n\\nfinished\\n{}\",\nbuffer_text(&active), buffer_text(&fading), buffer_text(&finished),)" ---- -before timeout -⠁ ⠄ ⠈ ⠁ ⠐ ⠄ ⢀ -›⠁Ask Codex to do anything ⠈ ⠁ ⠈ ⠄ ⠂ - ⠠⢀⠐ ⠄ ⠄ ⠠ ⠄⠂ ⡀ ⠈ ⠄ - ⠄ ⠐ - ⠈ ⠠ ⠠ ⢀⢀ ⠐ ⠂ ⠐ ⠐ ⡀ - ⠄ ⠄ ⢀ ⢀ ⠠ ⠠ ⠄ ⡀⠂ - ⠈ ⠁ ⠐ ⠐ ⢀ - ? for shortcuts 100% context left - -fading -⠁ ⠄ ⠈ ⠁ ⠐ ⠄ ⢀ -›⠁Ask Codex to do anything ⠈ ⠁ ⠈ ⠄ - ⠠⢀ ⠄⠂ ⡀ ⠈ ⠄ - ⠄ - ⠠ ⠠ ⢀⢀ ⠂ ⠐ ⡀ - ⠄ ⢀ ⠠ ⠠ ⠄ ⡀⠂ - ⠁ ⠐ ⠐ ⢀ - ? for shortcuts 100% context left - -finished - -› Ask Codex to do anything - - - - - - ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_placeholder_spaces.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_placeholder_spaces.snap deleted file mode 100644 index dbcc27329a..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_placeholder_spaces.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs -expression: buffer_text(&active) ---- - ⠈ ⢀ ⠈ ⠁ ⠐ ⠐ ⠐ ⠄ ⠈ -›⠁Ask Codex to do anything ⠈ ⠁ ⠁ ⠈ - ⠠ ⠄ ⢀ ⠄ ⠄⠈ ⠄ - ⠐ ⠄ ⡀ ⠠ - ⠈ ⠠⠁ ⠠⠠ ⢀ ⠂ - ⠄ ⠄ ⠐ ⠠ ⠂ ⡀ - ⠁ - ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_sparkle_interaction_40.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_sparkle_interaction_40.snap deleted file mode 100644 index 63a37a1e39..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__astra_sparkle_interaction_40.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs -expression: "format!(\"idle\\n{}\\n\\nfading\\n{}\\n\\nfinished\\n{}\", buffer_text(&active),\nbuffer_text(&fading), buffer_text(&finished),)" ---- -idle - ⢀ ⠁ ⢀ -› Ask Codex to do anything ⠈ - ⠠⢀ ⠠ ⠄ ⠠ - ? for shortcuts 100% context left - -fading - ⠁ ⢀ -› Ask Codex to do anything ⠈ - ⢀ ⠠ ⠄ - ? for shortcuts 100% context left - -finished - -› Ask Codex to do anything - - ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs deleted file mode 100644 index 6717185672..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Astra stars throughout the untouched composer, using the terminal's colors. -//! Selecting Astra starts one flourish; composer input fades it quickly, or 15 seconds after the -//! first visible frame starts a smooth fade. The deadline keeps running while terminal focus is -//! elsewhere. Only visible, eligible frames schedule more work, and the final frame clears the -//! stars without taking over the terminal's native mouse actions. - -use std::cell::Cell; -use std::sync::LazyLock; -use std::time::Duration; -use std::time::Instant; - -use codex_config::types::Tui; -use ratatui::buffer::Buffer; -use ratatui::buffer::CellDiffOption; -use ratatui::layout::Position; -use ratatui::layout::Rect; -use ratatui::style::Color; -use regex_lite::Regex; -use unicode_width::UnicodeWidthStr; - -use super::ChatComposer; -use super::popup_state::ActivePopup; -use crate::bottom_pane::BottomPane; -use crate::color::blend; -use crate::terminal_palette::StdoutColorLevel; -use crate::terminal_palette::default_fg; -use crate::terminal_palette::effective_stdout_color_level; -use crate::terminal_palette::rgb_color; - -const FRAME_TICK: Duration = Duration::from_millis(150); -const IDLE_TIMEOUT: Duration = Duration::from_secs(15); -const IDLE_FADE: Duration = Duration::from_secs(1); -const INTERACTION_FADE: Duration = Duration::from_millis(75); -const FADE_FRAME_TICK: Duration = Duration::from_millis(25); -const DOTS: [&str; 8] = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⡀", "⢀"]; -static ASTRA_MODEL: LazyLock = LazyLock::new(|| match Regex::new(r"(?i)\bastra\b") { - Ok(regex) => regex, - Err(error) => panic!("invalid Astra model regex: {error}"), -}); - -pub(super) struct Sparkle { - started: Instant, - phase: Cell, - terminal_focused: bool, -} - -#[derive(Clone, Copy)] -enum Phase { - Waiting, - Visible { - since: Instant, - elapsed: Duration, - }, - Fading { - started: Instant, - elapsed: Duration, - duration: Duration, - start_visibility: f32, - }, - Finished, -} - -struct SparkleFrame { - elapsed: Duration, - visibility: f32, - next: Duration, -} - -impl Sparkle { - fn enabled_foreground(&self) -> Option<(u8, u8, u8)> { - if matches!(self.phase.get(), Phase::Finished) - || effective_stdout_color_level() != StdoutColorLevel::TrueColor - { - return None; - } - // Read the cached palette here: Windows may populate it after widget creation. - default_fg() - } - - fn interact(&self, now: Instant) { - match self.phase.get() { - Phase::Waiting => self.phase.set(Phase::Finished), - Phase::Visible { since, elapsed } if now >= since + IDLE_TIMEOUT => { - self.phase.set(Phase::Fading { - started: since + IDLE_TIMEOUT, - elapsed, - duration: IDLE_FADE, - start_visibility: 1.0, - }); - self.interact(now); - } - Phase::Visible { elapsed, .. } => self.phase.set(Phase::Fading { - started: now, - elapsed, - duration: INTERACTION_FADE, - start_visibility: 1.0, - }), - Phase::Fading { - started, - elapsed, - duration: IDLE_FADE, - start_visibility, - } => { - let remaining = IDLE_FADE.saturating_sub(now.saturating_duration_since(started)); - if remaining.is_zero() { - self.phase.set(Phase::Finished); - } else { - // Input during the timed fade finishes quickly without brightening the dots. - self.phase.set(Phase::Fading { - started: now, - elapsed, - duration: INTERACTION_FADE.min(remaining), - start_visibility: start_visibility * remaining.as_secs_f32() - / IDLE_FADE.as_secs_f32(), - }); - } - } - Phase::Fading { .. } | Phase::Finished => {} - } - } - - fn frame(&self, now: Instant) -> Option { - let elapsed = now.saturating_duration_since(self.started); - match self.phase.get() { - Phase::Waiting => { - self.phase.set(Phase::Visible { - since: now, - elapsed, - }); - Some(SparkleFrame { - elapsed, - visibility: 1.0, - next: FRAME_TICK, - }) - } - Phase::Visible { - since, - elapsed: last_visible_elapsed, - } => { - let remaining_idle = - IDLE_TIMEOUT.saturating_sub(now.saturating_duration_since(since)); - if remaining_idle.is_zero() { - // Keep the last rendered dots in place, even if this frame arrives late. - self.phase.set(Phase::Fading { - started: since + IDLE_TIMEOUT, - elapsed: last_visible_elapsed, - duration: IDLE_FADE, - start_visibility: 1.0, - }); - return self.frame(now); - } - self.phase.set(Phase::Visible { since, elapsed }); - Some(SparkleFrame { - elapsed, - visibility: 1.0, - next: FRAME_TICK.min(remaining_idle), - }) - } - Phase::Fading { - started, - elapsed, - duration, - start_visibility, - } => { - let remaining_fade = - duration.saturating_sub(now.saturating_duration_since(started)); - if remaining_fade.is_zero() { - self.phase.set(Phase::Finished); - return None; - } - // Fade the last visible stars so neither timeout nor typing introduces dots. - Some(SparkleFrame { - elapsed, - visibility: start_visibility * remaining_fade.as_secs_f32() - / duration.as_secs_f32(), - next: FADE_FRAME_TICK.min(remaining_fade), - }) - } - Phase::Finished => None, - } - } -} - -impl BottomPane { - pub(crate) fn set_astra_sparkle(&mut self, model: &str, settings: &Tui) { - let changed = if !settings.whimsy || !settings.animations || !ASTRA_MODEL.is_match(model) { - self.composer.astra_sparkle.take().is_some() - } else if self.composer.astra_sparkle.is_none() { - self.composer.astra_sparkle = Some(Sparkle { - started: Instant::now(), - phase: Cell::new(Phase::Waiting), - terminal_focused: true, - }); - true - } else { - false - }; - if changed && let Some(requester) = &self.composer.frame_requester { - requester.schedule_frame(); - } - } - - pub(crate) fn set_sparkle_terminal_focus(&mut self, focused: bool) { - if let Some(sparkle) = &mut self.composer.astra_sparkle { - sparkle.terminal_focused = focused; - } - } -} - -impl ChatComposer { - pub(super) fn interact_with_astra_sparkle(&self) { - if let Some(sparkle) = &self.astra_sparkle { - sparkle.interact(Instant::now()); - } - } - - pub(super) fn render_sparkle( - &self, - area: Rect, - textarea: Rect, - cursor: Option<(u16, u16)>, - buf: &mut Buffer, - ) { - self.render_sparkle_at(area, textarea, cursor, Instant::now(), buf); - } - - fn render_sparkle_at( - &self, - area: Rect, - textarea: Rect, - cursor: Option<(u16, u16)>, - now: Instant, - buf: &mut Buffer, - ) { - if (!self.is_empty() - || self.draft.paste_burst.is_active() - || !self.draft.input_enabled - || self.voice_strip.is_some() - || !matches!(self.popups.active, ActivePopup::None)) - && let Some(sparkle) = &self.astra_sparkle - { - sparkle.interact(now); - } - if !self.has_focus || area.height < 3 || textarea.is_empty() { - return; - } - if let Some(sparkle) = &self.astra_sparkle - && sparkle.terminal_focused - && let Some(foreground) = sparkle.enabled_foreground() - && let Some(frame) = sparkle.frame(now) - { - let protected_area = if !self.is_empty() || self.draft.paste_burst.is_active() { - Rect::new(area.x, textarea.y, area.width, textarea.height) - } else { - let placeholder = if self.draft.input_enabled { - self.placeholder_text.as_str() - } else { - self.draft - .input_disabled_placeholder - .as_deref() - .unwrap_or("Input disabled.") - }; - // Spaces inside the placeholder are text too; the terminal can copy them. - Rect::new( - textarea.x, - textarea.y, - placeholder.width().min(usize::from(textarea.width)) as u16, - /*height*/ 1, - ) - }; - render_stars( - area, - cursor, - Some(protected_area), - frame.elapsed, - foreground, - frame.visibility, - buf, - ); - if let Some(requester) = &self.frame_requester { - requester.schedule_frame_in(frame.next); - } - } - } -} - -fn render_stars( - area: Rect, - cursor: Option<(u16, u16)>, - protected_area: Option, - elapsed: Duration, - foreground: (u8, u8, u8), - visibility: f32, - buf: &mut Buffer, -) { - let time = elapsed.as_secs_f32(); - for y in area.y..area.bottom() { - let mut occupied_until = area.x; - for x in area.x..area.right() { - let cell = &buf[(x, y)]; - if x < occupied_until { - continue; - } - if cell.symbol() != " " { - occupied_until = x.saturating_add(cell.symbol().width() as u16); - continue; - } - // Preserve the cursor, selections, and pixels from the effort bursts. - if cursor == Some((x, y)) - || protected_area.is_some_and(|protected| protected.contains(Position::new(x, y))) - || !cell.modifier.is_empty() - || cell.diff_option != CellDiffOption::None - { - continue; - } - let Color::Rgb(r, g, b) = cell.bg else { - continue; - }; - // A stable coordinate hash gives each star its own dot, period, and phase. - let mut hash = u64::from(y - area.y) * 65537 + u64::from(x - area.x); - hash = (hash ^ (hash >> 16)).wrapping_mul(0x45d9f3b); - hash = (hash ^ (hash >> 16)).wrapping_mul(0x45d9f3b); - hash ^= hash >> 16; - if hash % 5 != 0 { - continue; - } - let phase = - (time / (4.0 + (hash % 31) as f32 / 10.0) + (hash % 997) as f32 / 997.0).fract(); - let brightness = (phase * std::f32::consts::PI).sin().powi(12) * 0.55 * visibility; - if brightness < 0.04 { - continue; - } - buf[(x, y)] - .set_symbol(DOTS[(hash / 161 % 8) as usize]) - .set_fg(rgb_color(blend(foreground, (r, g, b), brightness))); - } - } -} - -#[cfg(test)] -#[path = "sparkle_tests.rs"] -mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs deleted file mode 100644 index acbf577ef4..0000000000 --- a/codex-rs/tui/src/bottom_pane/chat_composer/sparkle_tests.rs +++ /dev/null @@ -1,781 +0,0 @@ -use super::super::tests::new_test_composer; -use super::*; -use crate::render::renderable::Renderable; -use crate::terminal_palette::with_test_default_colors; -use crate::terminal_probe::DefaultColors; -use codex_protocol::openai_models::ReasoningEffort; -use pretty_assertions::assert_eq; -use ratatui::style::Style; - -const SPARKLE_PALETTE: DefaultColors = DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), -}; - -fn new_test_sparkle() -> Sparkle { - Sparkle { - started: Instant::now(), - phase: Cell::new(Phase::Waiting), - terminal_focused: true, - } -} - -fn new_test_pane() -> BottomPane { - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - BottomPane::new(crate::bottom_pane::BottomPaneParams { - app_event_tx: crate::app_event_sender::AppEventSender::new(tx), - frame_requester: crate::tui::FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".into(), - disable_paste_burst: true, - animations_enabled: true, - skills: None, - }) -} - -fn buffer_text(buffer: &Buffer) -> String { - buffer - .content - .chunks(usize::from(buffer.area.width)) - .map(|row| { - row.iter() - .map(ratatui::buffer::Cell::symbol) - .collect::() - .trim_end() - .to_owned() - }) - .collect::>() - .join("\n") -} - -fn rendered_sparkle( - composer: &ChatComposer, - area: Rect, - now: Instant, - baseline: &Buffer, -) -> Buffer { - let [surface, _, textarea, _] = composer.layout_areas(area); - let mut buffer = baseline.clone(); - composer.render_sparkle_at( - surface, - textarea, - composer.cursor_pos(area), - now, - &mut buffer, - ); - buffer -} - -#[test] -fn sparkle_keeps_the_existing_composer_layout() { - with_test_default_colors( - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - let (mut composer, _rx) = new_test_composer(); - composer.set_text_content("Explore the night sky".to_string(), Vec::new(), Vec::new()); - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - /*width*/ 60, - composer.desired_height(/*width*/ 60), - ); - let [surface, _, _, _] = composer.layout_areas(area); - let mut buffer = Buffer::empty(area); - composer.render(area, &mut buffer); - render_stars( - surface, - composer.cursor_pos(area), - /*protected_area*/ None, - Duration::ZERO, - /*foreground*/ (230, 216, 255), - /*visibility*/ 1.0, - &mut buffer, - ); - // Snapshot the composer layout without freezing the star distribution. - let rows = (area.y..area.bottom()) - .map(|y| { - (area.x..area.right()) - .map(|x| { - let symbol = buffer[(x, y)].symbol(); - if DOTS.contains(&symbol) { " " } else { symbol } - }) - .collect::() - }) - .collect::>() - .join("\n"); - insta::assert_snapshot!("astra_current_composer", rows); - }, - ); -} - -#[test] -fn sparkle_covers_the_untouched_composer_and_fades_after_interaction() { - with_test_default_colors(SPARKLE_PALETTE, || { - let width = 40; - let (mut composer, _rx) = new_test_composer(); - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - width, - composer.desired_height(width), - ); - let [_, _, textarea, _] = composer.layout_areas(area); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - let sparkle = new_test_sparkle(); - let started = sparkle.started; - composer.astra_sparkle = Some(sparkle); - - let idle = started + Duration::from_secs(63); - let active = rendered_sparkle(&composer, area, idle, &baseline); - let stars = active - .content - .iter() - .enumerate() - .filter_map(|(index, cell)| DOTS.contains(&cell.symbol()).then_some(index)) - .collect::>(); - let star_rows = stars - .iter() - .map(|index| active.pos_of(*index).1) - .collect::>(); - assert!(star_rows.iter().any(|y| *y < textarea.y)); - assert!( - star_rows - .iter() - .any(|y| (textarea.y..textarea.bottom()).contains(y)) - ); - assert!(star_rows.iter().any(|y| *y >= textarea.bottom())); - let cursor = composer.cursor_pos(area).unwrap(); - assert_eq!(active[cursor], baseline[cursor]); - - composer.astra_sparkle.as_ref().unwrap().interact(idle); - let fading = rendered_sparkle(&composer, area, idle + INTERACTION_FADE / 2, &baseline); - assert!(fading.content.iter().enumerate().all(|(index, cell)| { - !DOTS.contains(&cell.symbol()) || active.content[index].symbol() == cell.symbol() - })); - assert!(stars.iter().any(|index| { - let cell = &fading.content[*index]; - DOTS.contains(&cell.symbol()) && cell.fg != active.content[*index].fg - })); - - let finished = rendered_sparkle(&composer, area, idle + INTERACTION_FADE, &baseline); - assert_eq!(finished, baseline); - insta::assert_snapshot!( - format!("astra_sparkle_interaction_{width}"), - format!( - "idle\n{}\n\nfading\n{}\n\nfinished\n{}", - buffer_text(&active), - buffer_text(&fading), - buffer_text(&finished), - ) - ); - }); -} - -#[test] -fn sparkle_auto_fades_fifteen_seconds_after_its_first_visible_frame() { - with_test_default_colors(SPARKLE_PALETTE, || { - let (mut composer, _rx) = new_test_composer(); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8, - ); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - composer.astra_sparkle = Some(new_test_sparkle()); - let first_frame = composer.astra_sparkle.as_ref().unwrap().started - + Duration::from_secs(/*secs*/ 63); - let first = rendered_sparkle(&composer, area, first_frame, &baseline); - assert_ne!(first, baseline); - - let deadline = first_frame + IDLE_TIMEOUT; - let just_before = deadline - Duration::from_millis(/*millis*/ 50); - let active = rendered_sparkle(&composer, area, just_before, &baseline); - assert_ne!(active, baseline); - assert_eq!( - composer - .astra_sparkle - .as_ref() - .unwrap() - .frame(just_before) - .unwrap() - .next, - Duration::from_millis(/*millis*/ 50) - ); - - let sparkle = composer.astra_sparkle.as_ref().unwrap(); - assert_eq!( - [ - sparkle.frame(deadline).unwrap().visibility, - sparkle.frame(deadline + IDLE_FADE / 4).unwrap().visibility, - sparkle.frame(deadline + IDLE_FADE / 2).unwrap().visibility, - ], - [1.0, 0.75, 0.5] - ); - let fading = rendered_sparkle(&composer, area, deadline + IDLE_FADE / 2, &baseline); - assert!(fading.content.iter().enumerate().all(|(index, cell)| { - !DOTS.contains(&cell.symbol()) || active.content[index].symbol() == cell.symbol() - })); - let finished = rendered_sparkle(&composer, area, deadline + IDLE_FADE, &baseline); - assert_eq!(finished, baseline); - assert!(matches!( - composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Finished - )); - insta::assert_snapshot!( - "astra_idle_timeout", - format!( - "before timeout\n{}\n\nfading\n{}\n\nfinished\n{}", - buffer_text(&active), - buffer_text(&fading), - buffer_text(&finished), - ) - ); - }); -} - -#[test] -fn sparkle_preserves_placeholder_spaces_during_idle_animation() { - with_test_default_colors(SPARKLE_PALETTE, || { - let (mut composer, _rx) = new_test_composer(); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8, - ); - let [_, _, textarea, _] = composer.layout_areas(area); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - composer.astra_sparkle = Some(new_test_sparkle()); - let start = composer.astra_sparkle.as_ref().unwrap().started - + Duration::from_secs(/*secs*/ 63); - let end = textarea.x + composer.placeholder_text.width() as u16; - for second in 0..IDLE_TIMEOUT.as_secs() { - let active = rendered_sparkle( - &composer, - area, - start + Duration::from_secs(second), - &baseline, - ); - for x in textarea.x..end { - assert_eq!(active[(x, textarea.y)], baseline[(x, textarea.y)]); - } - assert_ne!(active, baseline); - if second == 2 { - insta::assert_snapshot!("astra_placeholder_spaces", buffer_text(&active)); - } - } - }); -} - -#[test] -fn idle_deadline_elapses_while_terminal_focus_is_elsewhere() { - with_test_default_colors(SPARKLE_PALETTE, || { - let (mut composer, _rx) = new_test_composer(); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8, - ); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - composer.astra_sparkle = Some(new_test_sparkle()); - let first_frame = composer.astra_sparkle.as_ref().unwrap().started; - let active = rendered_sparkle(&composer, area, first_frame, &baseline); - assert_ne!(active, baseline); - composer.astra_sparkle.as_mut().unwrap().terminal_focused = false; - let after_deadline = first_frame + IDLE_TIMEOUT + IDLE_FADE; - let hidden = rendered_sparkle(&composer, area, after_deadline, &baseline); - assert_eq!(hidden, baseline); - assert!(matches!( - composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Visible { .. } - )); - composer.astra_sparkle.as_mut().unwrap().terminal_focused = true; - assert_eq!( - rendered_sparkle(&composer, area, after_deadline, &baseline), - baseline - ); - assert!(matches!( - composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Finished - )); - }); -} - -#[test] -fn input_during_the_idle_fade_finishes_quickly_without_brightening() { - let sparkle = new_test_sparkle(); - let first_frame = sparkle.started; - sparkle.frame(first_frame).unwrap(); - sparkle - .frame(first_frame + IDLE_TIMEOUT - Duration::from_millis(/*millis*/ 50)) - .unwrap(); - let interaction = first_frame + IDLE_TIMEOUT + IDLE_FADE / 2; - let before = sparkle.frame(interaction).unwrap().visibility; - sparkle.interact(interaction); - assert_eq!(sparkle.frame(interaction).unwrap().visibility, before); - assert!(sparkle.frame(interaction + INTERACTION_FADE).is_none()); -} - -#[test] -fn typing_and_paste_start_one_fade_without_decorating_the_draft() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - - with_test_default_colors(SPARKLE_PALETTE, || { - for paste in [false, true] { - let (mut composer, _rx) = new_test_composer(); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 8, - ); - composer.astra_sparkle = Some(new_test_sparkle()); - let mut initial = Buffer::empty(area); - composer.render(area, &mut initial); - - if paste { - composer.handle_paste("first line\n中文 words".to_owned()); - } else { - composer.handle_key_event(KeyEvent::from(KeyCode::Char('a'))); - composer.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); - } - let sparkle = composer.astra_sparkle.take().unwrap(); - let Phase::Fading { started, .. } = sparkle.phase.get() else { - panic!("first input should start fading"); - }; - let [surface, _, textarea, _] = composer.layout_areas(area); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - composer.astra_sparkle = Some(sparkle); - - let fading = - rendered_sparkle(&composer, area, started + INTERACTION_FADE / 2, &baseline); - for y in textarea.y..textarea.bottom() { - for x in surface.x..surface.right() { - assert_eq!(fading[(x, y)], baseline[(x, y)]); - } - } - assert_eq!( - rendered_sparkle(&composer, area, started + INTERACTION_FADE, &baseline), - baseline - ); - composer.set_text_content(String::new(), Vec::new(), Vec::new()); - assert!(matches!( - composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Finished - )); - } - }); -} - -#[tokio::test(flavor = "current_thread", start_paused = true)] -async fn sparkle_stops_scheduling_after_the_fade() { - let (mut composer, _rx) = new_test_composer(); - let (draw_tx, mut draw_rx) = tokio::sync::broadcast::channel(/*capacity*/ 4); - composer.set_frame_requester(crate::tui::FrameRequester::new(draw_tx)); - composer.astra_sparkle = Some(new_test_sparkle()); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 3, - ); - let render = |composer: &ChatComposer| { - with_test_default_colors(SPARKLE_PALETTE, || { - composer.render(area, &mut Buffer::empty(area)) - }); - }; - render(&composer); - assert!( - tokio::time::timeout(FRAME_TICK * 2, draw_rx.recv()) - .await - .is_ok() - ); - composer - .astra_sparkle - .as_ref() - .expect("sparkle") - .interact(Instant::now() - INTERACTION_FADE); - render(&composer); - assert!( - tokio::time::timeout(FRAME_TICK * 2, draw_rx.recv()) - .await - .is_err() - ); -} - -#[test] -fn sparkle_preserves_content_cursor_and_background() { - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3, - ); - let mut before = Buffer::empty(area); - before.set_style(area, Style::default().bg(rgb_color((36, 27, 53)))); - before[(0, 0)].set_symbol("界"); - before[(2, 0)].set_symbol("!"); - before[(4, 0)].set_symbol("✦"); - before[(5, 0)].set_style(Style::default().reversed()); - before[(6, 0)].set_diff_option(CellDiffOption::Skip); - let protected = [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0), (6, 0)]; - let mut seen = std::collections::HashMap::new(); - for tick in 0..80 { - let mut after = before.clone(); - render_stars( - area, - Some((3, 0)), - /*protected_area*/ None, - FRAME_TICK * tick, - /*foreground*/ (230, 216, 255), - /*visibility*/ 1.0, - &mut after, - ); - assert_eq!( - protected.map(|p| after[p].clone()), - protected.map(|p| before[p].clone()) - ); - for (index, cell) in after.content.iter().enumerate() { - assert_eq!(cell.bg, before.content[index].bg); - if DOTS.contains(&cell.symbol()) - && let Some(previous) = seen.insert(index, cell.symbol().to_string()) - { - assert_eq!(cell.symbol(), previous); - } - } - } - assert!(!seen.is_empty()); -} - -#[test] -fn stars_fade_using_the_custom_terminal_foreground() { - for colors in [ - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - DefaultColors { - fg: (101, 123, 131), - bg: (253, 246, 227), - }, - ] { - with_test_default_colors(colors, || { - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3, - ); - let mut shades: std::collections::HashMap> = - std::collections::HashMap::new(); - for tick in 0..40 { - let mut buffer = Buffer::empty(area); - buffer.set_style(area, Style::default().bg(rgb_color(colors.bg))); - render_stars( - area, - /*cursor*/ None, - /*protected_area*/ None, - FRAME_TICK * tick, - colors.fg, - /*visibility*/ 1.0, - &mut buffer, - ); - for (index, cell) in buffer.content.iter().enumerate() { - if DOTS.contains(&cell.symbol()) { - let Color::Rgb(r, g, b) = cell.fg else { - panic!("expected RGB fade") - }; - shades.entry(index).or_default().insert(cell.fg); - for (actual, fg, bg) in [ - (r, colors.fg.0, colors.bg.0), - (g, colors.fg.1, colors.bg.1), - (b, colors.fg.2, colors.bg.2), - ] { - assert!((fg.min(bg)..=fg.max(bg)).contains(&actual)); - } - assert_eq!(cell.bg, rgb_color(colors.bg)); - } - } - } - assert!(shades.values().any(|colors| colors.len() > 3)); - }); - } -} - -#[test] -fn offline_composer_keys_fade_stars() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyEventKind; - use crossterm::event::KeyModifiers; - - with_test_default_colors(SPARKLE_PALETTE, || { - for code in [KeyCode::Enter, KeyCode::Tab, KeyCode::Left] { - let mut pane = new_test_pane(); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 6, - ); - pane.composer.astra_sparkle = Some(new_test_sparkle()); - pane.composer.render(area, &mut Buffer::empty(area)); - pane.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); - assert!(matches!( - pane.composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Visible { .. } - )); - pane.handle_disconnected_key(KeyEvent::new_with_kind( - code, - KeyModifiers::NONE, - KeyEventKind::Release, - )); - assert!(matches!( - pane.composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Visible { .. } - )); - - pane.handle_disconnected_key(KeyEvent::new(code, KeyModifiers::NONE)); - assert!(matches!( - pane.composer.astra_sparkle.as_ref().unwrap().phase.get(), - Phase::Fading { .. } - )); - assert_eq!(pane.composer.current_text(), ""); - } - }); -} - -#[test] -fn model_changes_and_disable_setting_control_sparkle() { - let mut pane = new_test_pane(); - with_test_default_colors( - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - let mut settings = Tui { - whimsy: true, - animations: true, - ..Tui::default() - }; - pane.set_astra_sparkle("gpt-6-astra", &settings); - let started = pane - .composer - .astra_sparkle - .as_ref() - .map(|sparkle| sparkle.started); - pane.set_sparkle_terminal_focus(/*focused*/ false); - pane.set_sparkle_terminal_focus(/*focused*/ true); - for model in ["astra", "ASTRA-preview", "openai/astra-2026-09-01"] { - pane.set_astra_sparkle(model, &settings); - assert_eq!( - pane.composer - .astra_sparkle - .as_ref() - .and_then(Sparkle::enabled_foreground), - Some((230, 216, 255)), - ); - assert_eq!( - pane.composer - .astra_sparkle - .as_ref() - .map(|sparkle| sparkle.started), - started - ); - } - pane.composer.interact_with_astra_sparkle(); - pane.set_astra_sparkle("gpt-6-astra", &settings); - assert_eq!( - pane.composer - .astra_sparkle - .as_ref() - .and_then(Sparkle::enabled_foreground), - None, - ); - for model in [ - "gpt-5.6-sol", - "astral", - "castrated", - "astra2", - "astra_preview", - ] { - pane.set_astra_sparkle(model, &settings); - assert_eq!( - pane.composer - .astra_sparkle - .as_ref() - .and_then(Sparkle::enabled_foreground), - None, - "{model}", - ); - } - for (whimsy, animations) in [(false, true), (true, false), (false, false), (true, true)] - { - settings.whimsy = whimsy; - settings.animations = animations; - pane.set_astra_sparkle("astra", &settings); - assert_eq!( - pane.composer - .astra_sparkle - .as_ref() - .and_then(Sparkle::enabled_foreground), - (whimsy && animations).then_some((230, 216, 255)), - ); - } - }, - ); -} - -#[test] -fn sparkle_yields_to_drafts_effort_bursts_and_popups() { - with_test_default_colors( - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - let (mut composer, _rx) = new_test_composer(); - composer.astra_sparkle = Some(new_test_sparkle()); - composer.set_text_content("hello 界".to_string(), Vec::new(), Vec::new()); - composer.set_active_reasoning_effort_baseline(Some(&ReasoningEffort::High)); - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - /*width*/ 80, - composer.desired_height(/*width*/ 80), - ); - for effort in [ReasoningEffort::Max, ReasoningEffort::Ultra] { - composer - .set_active_reasoning_effort(Some(&effort), /*animations_enabled*/ true); - let started = composer.astra_sparkle.take(); - let mut before = Buffer::empty(area); - composer.render(area, &mut before); - composer.astra_sparkle = started; - let mut after = Buffer::empty(area); - composer.render(area, &mut after); - let text = after - .content - .iter() - .map(ratatui::buffer::Cell::symbol) - .collect::(); - assert!(text.contains("hello 界")); - assert!( - after - .content - .iter() - .all(|cell| !DOTS.contains(&cell.symbol())) - ); - assert_eq!( - composer.cursor_pos(area).map(|p| after[p].clone()), - composer.cursor_pos(area).map(|p| before[p].clone()) - ); - assert!(composer.effort_ignition.is_some()); - } - composer.set_text_content("/mod".to_string(), Vec::new(), Vec::new()); - composer.draft.textarea.set_cursor(/*pos*/ 4); - composer.sync_popups(); - assert!(!matches!(composer.popups.active, ActivePopup::None)); - let mut buffer = Buffer::empty(area); - let [surface, _, textarea, _] = composer.layout_areas(area); - composer.render_sparkle(surface, textarea, /*cursor*/ None, &mut buffer); - assert_eq!(buffer, Buffer::empty(area)); - }, - ); -} - -#[test] -fn sparkle_waits_for_terminal_colors() { - let (mut composer, _rx) = new_test_composer(); - composer.astra_sparkle = Some(new_test_sparkle()); - let area = Rect::new( - /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 3, - ); - let [surface, _, textarea, _] = composer.layout_areas(area); - let mut buffer = Buffer::empty(area); - composer.render_sparkle(surface, textarea, /*cursor*/ None, &mut buffer); - assert_eq!(buffer, Buffer::empty(area)); - with_test_default_colors( - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - buffer.set_style(area, Style::default().bg(rgb_color((36, 27, 53)))); - composer.render_sparkle(surface, textarea, /*cursor*/ None, &mut buffer); - assert!( - buffer - .content - .iter() - .any(|cell| DOTS.contains(&cell.symbol())) - ); - }, - ); -} - -#[test] -fn sparkle_preserves_voice_indicator_styles() { - use crate::bottom_pane::voice_strip::VoiceStripPhase; - use crate::bottom_pane::voice_strip::VoiceStripState; - use crate::tui::FrameRequester; - - with_test_default_colors( - DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - let (mut composer, _rx) = new_test_composer(); - composer.set_voice_strip( - Some(VoiceStripState { - mute_hint: None, - phase: VoiceStripPhase::Active, - microphone_live: true, - microphone_muted: false, - microphone_history: vec![0, 37, 73, 110, 146, 255], - speaker_history: vec![255, 146, 110, 73, 37, 0], - activity: "listening", - animations: false, - }), - FrameRequester::test_dummy(), - ); - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - /*width*/ 60, - composer.desired_height(/*width*/ 60), - ); - let mut baseline = Buffer::empty(area); - composer.render(area, &mut baseline); - let mut saw_stars = false; - for tick in 0..80 { - composer.astra_sparkle = Some(Sparkle { - started: Instant::now() - FRAME_TICK * tick, - ..new_test_sparkle() - }); - let mut actual = Buffer::empty(area); - composer.render(area, &mut actual); - saw_stars |= actual - .content - .iter() - .any(|cell| DOTS.contains(&cell.symbol())); - // Compare whole cells: an unchanged glyph can still inherit a star's color. - let content = baseline - .content - .iter() - .enumerate() - .filter(|(_, cell)| cell.symbol() != " ") - .map(|(index, _)| actual.content[index].clone()) - .collect::>(); - let expected = baseline - .content - .iter() - .filter(|cell| cell.symbol() != " ") - .cloned() - .collect::>(); - assert_eq!(content, expected, "sparkle frame {tick}"); - } - assert!(!saw_stars); - insta::assert_snapshot!( - "astra_voice_composer", - baseline - .content - .chunks(60) - .map(|row| row - .iter() - .map(ratatui::buffer::Cell::symbol) - .collect::()) - .collect::>() - .join("\n") - ); - }, - ); -} diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index af8e867122..f04c2f627f 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -319,10 +319,6 @@ impl ChatWidget { .bottom_pane .set_token_activity_command_enabled(widget.has_codex_backend_auth); widget.refresh_status_surfaces(); - widget.bottom_pane.set_astra_sparkle( - widget.effective_collaboration_mode().model(), - &widget.local_settings.tui, - ); widget } diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 683ad9e52d..b087aa4f12 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -425,15 +425,9 @@ impl ChatWidget { ) } - pub(crate) fn set_sparkle_terminal_focus(&mut self, focused: bool) { - self.bottom_pane.set_sparkle_terminal_focus(focused); - } - pub(super) fn refresh_model_display(&mut self) { let effective = self.effective_collaboration_mode(); self.session_header.set_model(effective.model()); - self.bottom_pane - .set_astra_sparkle(effective.model(), &self.local_settings.tui); // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); self.sync_service_tier_commands(); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d41c6e540a..edf288001b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -11,31 +11,6 @@ use ratatui::Terminal; use ratatui::backend::TestBackend; use serial_test::serial; -#[tokio::test] -async fn astra_sparkle_pauses_and_resumes_with_terminal_focus() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.6-sol")).await; - chat.local_settings.tui.animations = true; - chat.local_settings.tui.whimsy = true; - chat.set_model("gpt-6-astra"); - crate::terminal_palette::with_test_default_colors( - crate::terminal_probe::DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - let contains_stars = |text: &str| text.chars().any(|ch| "⠁⠂⠄⠈⠐⠠⡀⢀".contains(ch)); - chat.set_sparkle_terminal_focus(/*focused*/ false); - assert!(!contains_stars(&render_bottom_popup( - &chat, /*width*/ 80 - ))); - chat.set_sparkle_terminal_focus(/*focused*/ true); - assert!(contains_stars(&render_bottom_popup( - &chat, /*width*/ 80 - ))); - }, - ); -} - #[tokio::test] async fn voice_live_transcript_renders_beside_the_streamed_cell() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 7ad211c0e0..537a1dfc87 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -5,72 +5,6 @@ use codex_config::LoaderOverrides; use codex_config::types::SessionPickerViewMode; use pretty_assertions::assert_eq; -#[tokio::test] -async fn system_reduced_motion_renders_astra_composer_without_sparkles() -> anyhow::Result<()> { - use crate::app_event_sender::AppEventSender; - use crate::bottom_pane::BottomPane; - use crate::bottom_pane::BottomPaneParams; - use crate::motion::MotionMode; - use crate::render::renderable::Renderable; - use crate::tui::FrameRequester; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let home = tempfile::tempdir()?; - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .loader_overrides(LoaderOverrides { - ignore_project_config: true, - ..LoaderOverrides::without_managed_config_for_tests() - }) - .build() - .await?; - let settings = LocalSettings::with_system_motion(&config, MotionMode::Reduced); - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: AppEventSender::new(tx), - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".into(), - disable_paste_burst: true, - animations_enabled: settings.tui.animations, - skills: None, - }); - pane.set_astra_sparkle("astra", &settings.tui); - pane.set_composer_text("Explore the night sky".into(), Vec::new(), Vec::new()); - crate::terminal_palette::with_test_default_colors( - crate::terminal_probe::DefaultColors { - fg: (230, 216, 255), - bg: (36, 27, 53), - }, - || { - for width in [40, 80] { - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - width, - pane.desired_height(width), - ); - let mut buffer = Buffer::empty(area); - pane.render(area, &mut buffer); - let rows = buffer - .content - .chunks(usize::from(width)) - .map(|row| { - row.iter() - .map(ratatui::buffer::Cell::symbol) - .collect::() - }) - .collect::>() - .join("\n"); - insta::assert_snapshot!(format!("system_reduced_motion_astra_{width}"), rows); - } - }, - ); - Ok(()) -} - #[tokio::test] async fn system_motion_suppresses_animations_without_changing_saved_preferences() -> anyhow::Result<()> { diff --git a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap deleted file mode 100644 index 80e58128bb..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_40.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/local_settings_tests.rs -assertion_line: 57 -expression: rows ---- - -› Explore the night sky - - 100% context left diff --git a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap b/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap deleted file mode 100644 index 910f92e827..0000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__local_settings__tests__system_reduced_motion_astra_80.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/local_settings_tests.rs -assertion_line: 57 -expression: rows ---- - -› Explore the night sky - - 100% context left