Add Astra sparkle effects to the TUI composer (#42842)

## What changed

- Render sparse, fading stars across the composer when using an Astra model in a true-color terminal.
- Preserve composer content, cursor state, effort effects, and terminal colors, and pause the animation while popups are open.
- Add the default-on `tui.whimsy` setting so decorative effects can be disabled independently. Sparkles also respect `tui.animations`.

## Testing

- Cover model and setting eligibility, terminal color handling, protected composer cells, popup behavior, effort effects, and layout stability.

GitOrigin-RevId: fe0471ebef0bca21c44e1d8f731d46959eba0165
This commit is contained in:
Ian MacLeod
2026-09-04 20:18:13 +00:00
committed by copyberry
parent 60888d0868
commit 147137c1f4
13 changed files with 498 additions and 0 deletions
+5
View File
@@ -731,6 +731,11 @@ pub struct Tui {
#[serde(default = "default_true")]
pub animations: bool,
/// Enable decorative effects such as Astra composer stars. Also requires animations.
/// Defaults to `true`.
#[serde(default = "default_true")]
pub whimsy: bool,
/// Show startup tooltips in the TUI welcome screen.
/// Defaults to `true`.
#[serde(default = "default_true")]
+5
View File
@@ -4150,6 +4150,11 @@
"default": false,
"description": "Start the composer in Vim mode (`Normal`) by default. Defaults to `false`.",
"type": "boolean"
},
"whimsy": {
"default": true,
"description": "Enable decorative effects such as Astra composer stars. Also requires animations. Defaults to `true`.",
"type": "boolean"
}
},
"type": "object"
+2
View File
@@ -1256,6 +1256,7 @@ fn config_toml_deserializes_model_availability_nux() {
Tui {
notification_settings: TuiNotificationSettings::default(),
animations: true,
whimsy: true,
show_tooltips: true,
auto_recap: true,
disable_paste_burst: None,
@@ -4269,6 +4270,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
Tui {
notification_settings: TuiNotificationSettings::default(),
animations: true,
whimsy: true,
show_tooltips: true,
auto_recap: true,
disable_paste_burst: None,
+4
View File
@@ -740,6 +740,9 @@ pub struct Config {
/// Enable ASCII animations and shimmer effects in the TUI.
pub animations: bool,
/// Enable decorative TUI effects such as Astra composer stars.
pub tui_whimsy: bool,
/// Show startup tooltips in the TUI welcome screen.
pub show_tooltips: bool,
@@ -4343,6 +4346,7 @@ impl Config {
.map(|t| t.notification_settings.clone())
.unwrap_or_default(),
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
tui_whimsy: cfg.tui.as_ref().map(|t| t.whimsy).unwrap_or(true),
show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true),
tui_auto_recap: cfg.tui.as_ref().map(|t| t.auto_recap).unwrap_or(/*default*/ true),
model_availability_nux: cfg
@@ -225,6 +225,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
notify: None,
tui_notifications: TuiNotificationSettings::default(),
animations: true,
tui_whimsy: true,
show_tooltips: true,
tui_auto_recap: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
@@ -316,6 +316,7 @@ mod history_search;
mod popup_state;
mod reconnect;
mod slash_input;
mod sparkle;
mod vim_history;
mod vim_search;
@@ -526,6 +527,7 @@ pub(crate) struct ChatComposer {
effort_tier: Option<EffortTier>,
effort_animation_style: Option<IgnitionStyle>,
effort_ignition: Option<EffortIgnition>,
astra_sparkle: Option<sparkle::Sparkle>,
effort_status_line_transition: Option<EffortStatusLineTransition>,
effort_observed: bool,
luna_reserve_active: bool,
@@ -706,6 +708,7 @@ impl ChatComposer {
effort_tier: None,
effort_animation_style: None,
effort_ignition: None,
astra_sparkle: None,
effort_status_line_transition: None,
effort_observed: false,
luna_reserve_active: false,
@@ -5063,6 +5066,14 @@ impl ChatComposer {
frame_requester.schedule_frame_in(IGNITION_FRAME_TICK);
}
}
drop(state);
if self.astra_sparkle.is_some() {
self.render_sparkle(
composer_rect,
self.cursor_pos_with_textarea_right_reserve(area, textarea_right_reserve),
buf,
);
}
}
}
@@ -0,0 +1,9 @@
---
source: tui/src/bottom_pane/chat_composer/sparkle_tests.rs
assertion_line: 48
expression: rows
---
› Explore the night sky
100% context left
@@ -0,0 +1,138 @@
//! Sparse Astra stars on the existing composer surface, fading with the terminal's colors.
//! `enabled_foreground` owns eligibility, including late-arriving terminal colors.
//! Rendering owns frame scheduling, so hidden composers do not keep animating.
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::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 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 {
model: String,
whimsy: bool,
animations: bool,
started: Instant,
}
impl Sparkle {
fn enabled_foreground(&self) -> Option<(u8, u8, u8)> {
if !self.whimsy
|| !self.animations
|| !ASTRA_MODEL.is_match(&self.model)
|| effective_stdout_color_level() != StdoutColorLevel::TrueColor
{
return None;
}
// Read the cached palette here: Windows may populate it after widget creation.
default_fg()
}
}
impl BottomPane {
pub(crate) fn set_astra_sparkle(&mut self, model: &str, settings: &Tui) {
let started = self
.composer
.astra_sparkle
.as_ref()
.map_or_else(Instant::now, |sparkle| sparkle.started);
self.composer.astra_sparkle = Some(Sparkle {
model: model.to_owned(),
whimsy: settings.whimsy,
animations: settings.animations,
started,
});
}
}
impl ChatComposer {
pub(super) fn render_sparkle(&self, area: Rect, cursor: Option<(u16, u16)>, buf: &mut Buffer) {
if area.is_empty() || !matches!(self.popups.active, ActivePopup::None) {
return;
}
if let Some(sparkle) = &self.astra_sparkle
&& let Some(foreground) = sparkle.enabled_foreground()
{
render_stars(area, cursor, sparkle.started.elapsed(), foreground, buf);
if let Some(requester) = &self.frame_requester {
requester.schedule_frame_in(FRAME_TICK);
}
}
}
}
fn render_stars(
area: Rect,
cursor: Option<(u16, u16)>,
elapsed: Duration,
foreground: (u8, u8, u8),
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))
|| !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;
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;
@@ -0,0 +1,314 @@
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;
fn new_test_sparkle() -> Sparkle {
Sparkle {
model: "astra".to_string(),
whimsy: true,
animations: true,
started: Instant::now(),
}
}
#[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),
Duration::ZERO,
/*foreground*/ (230, 216, 255),
&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_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)),
FRAME_TICK * tick,
/*foreground*/ (230, 216, 255),
&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,
FRAME_TICK * tick,
colors.fg,
&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 model_changes_and_disable_setting_control_sparkle() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut pane = 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: String::new(),
disable_paste_burst: true,
animations_enabled: true,
skills: None,
});
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);
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
);
}
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_renders_with_effort_bursts_and_pauses_for_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()
.any(|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);
composer.render_sparkle(area, /*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 mut buffer = Buffer::empty(area);
composer.render_sparkle(area, /*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(area, /*cursor*/ None, &mut buffer);
assert!(
buffer
.content
.iter()
.any(|cell| DOTS.contains(&cell.symbol()))
);
},
);
}
@@ -309,6 +309,10 @@ 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
}
+2
View File
@@ -442,6 +442,8 @@ impl ChatWidget {
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();
+1
View File
@@ -27,6 +27,7 @@ impl From<&Config> for LocalSettings {
tui: Tui {
notification_settings: config.tui_notifications.clone(),
animations: config.animations,
whimsy: config.tui_whimsy,
show_tooltips: config.show_tooltips,
auto_recap: config.tui_auto_recap,
disable_paste_burst: Some(config.disable_paste_burst),
+2
View File
@@ -12,6 +12,7 @@ async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Resul
r#"
[tui]
animations = false
whimsy = false
show_tooltips = false
auto_recap = false
vim_mode_default = true
@@ -41,6 +42,7 @@ fast_default_opt_out = true
expected.session_picker_view = Some(SessionPickerViewMode::Dense);
if !config_text.is_empty() {
expected.animations = false;
expected.whimsy = false;
expected.show_tooltips = false;
expected.auto_recap = false;
expected.vim_mode_default = true;