Remove Astra sparkle animation from the TUI composer (#45137)

## What changed

Remove the animated stars shown when selecting Astra, along with their input, terminal-focus, and model-selection hooks.

## Testing

Retain draft text and live voice control snapshots in a standalone composer snapshot test, and remove sparkle-specific tests and snapshots.

GitOrigin-RevId: cbe4396e8700cabfb1e0db9fbe8f997b94316c0f
This commit is contained in:
Felipe Coury
2026-09-13 00:01:05 +00:00
committed by copyberry
parent b966240bea
commit 7efa9d96fb
17 changed files with 79 additions and 1346 deletions
-4
View File
@@ -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<Rect> {
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)
+5 -23
View File
@@ -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<IgnitionStyle>,
effort_ignition: Option<EffortIgnition>,
voice_strip: Option<VoiceStrip>,
astra_sparkle: Option<sparkle::Sparkle>,
effort_status_line_transition: Option<EffortStatusLineTransition>,
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;
@@ -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;
@@ -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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let name = if voice_active {
"voice_composer"
} else {
"draft_composer"
};
insta::assert_snapshot!(name, rows);
}
},
);
}
@@ -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
---
@@ -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::<String>()).collect::<Vec<_>>().join(\"\\n\")"
source: tui/src/bottom_pane/chat_composer/snapshot_tests.rs
expression: rows
---
voice ● listening /voice mute /voice stop
@@ -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
@@ -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
@@ -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
@@ -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<Regex> = 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<Phase>,
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<SparkleFrame> {
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<Rect>,
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;
@@ -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::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.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::<String>()
})
.collect::<Vec<_>>()
.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::<Vec<_>>();
let star_rows = stars
.iter()
.map(|index| active.pos_of(*index).1)
.collect::<Vec<_>>();
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<usize, std::collections::HashSet<Color>> =
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::<String>();
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::<Vec<_>>();
let expected = baseline
.content
.iter()
.filter(|cell| cell.symbol() != " ")
.cloned()
.collect::<Vec<_>>();
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::<String>())
.collect::<Vec<_>>()
.join("\n")
);
},
);
}
@@ -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
}
-6
View File
@@ -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();
@@ -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;
-66
View File
@@ -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::<String>()
})
.collect::<Vec<_>>()
.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<()> {
@@ -1,9 +0,0 @@
---
source: tui/src/local_settings_tests.rs
assertion_line: 57
expression: rows
---
› Explore the night sky
100% context left
@@ -1,9 +0,0 @@
---
source: tui/src/local_settings_tests.rs
assertion_line: 57
expression: rows
---
› Explore the night sky
100% context left