mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
-2
@@ -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
|
||||
---
|
||||
|
||||
+2
-3
@@ -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
|
||||
-34
@@ -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
|
||||
-12
@@ -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
|
||||
-21
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/local_settings_tests.rs
|
||||
assertion_line: 57
|
||||
expression: rows
|
||||
---
|
||||
|
||||
› Explore the night sky
|
||||
|
||||
100% context left
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/local_settings_tests.rs
|
||||
assertion_line: 57
|
||||
expression: rows
|
||||
---
|
||||
|
||||
› Explore the night sky
|
||||
|
||||
100% context left
|
||||
Reference in New Issue
Block a user