Animate Max and Ultra reasoning effort changes (#34365)

## What changed

- Add one-shot composer and status-line transitions when the active reasoning effort changes to Max or Ultra.
- Keep a tier-specific prompt accent after the transition, including a distinct Ultra prompt glyph.
- Preserve drafts and footer priority, avoid replaying effects for restored or unchanged settings, and disable motion when animations or sufficient terminal color support are unavailable.

## Testing

- Add unit and snapshot coverage for animation frames, prompt accents, status-line transitions, narrow layouts, terminal palettes, restored sessions, and reduced-motion behavior.

GitOrigin-RevId: 6da1d501d228d0cf1144b3d211d5d1304a06f447
This commit is contained in:
Felipe Coury
2026-07-20 15:35:12 +00:00
committed by copyberry
parent 1e20272fa5
commit f944456d81
17 changed files with 1742 additions and 4 deletions
+139 -2
View File
@@ -87,6 +87,16 @@
//! shell commands return `ParentOwnedInputBlocked` without clearing the draft. Bare local and
//! navigation slash commands remain available so users can leave or manage the view.
//!
//! # Reasoning Effort Animations
//!
//! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh.
//! The first observation, session configuration, and restored threads establish a baseline;
//! genuine changes to Max/Ultra can then queue composer and status-line transitions when motion
//! and color support allow them.
//! Repeated selections do not restart either effect, dropping below Max clears them, and
//! restoration clears any transition queued while replaying the saved session. Rendering advances
//! active transitions through the frame requester until they finish.
//!
//! # Large Paste Placeholders
//!
//! Large pastes insert an element placeholder in the buffer and store the full text in
@@ -184,11 +194,19 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::WidgetRef;
use codex_protocol::openai_models::ReasoningEffort;
use super::chat_composer_history::ChatComposerHistory;
use super::chat_composer_history::HistoryEntry;
use super::chat_composer_history::HistoryEntryResponse;
use super::chat_composer_history::HistorySearchResult;
use super::command_popup::CommandItem;
use super::effort_ignition::EffortIgnition;
use super::effort_ignition::EffortTier;
use super::effort_ignition::IGNITION_FRAME_TICK;
use super::effort_ignition::IgnitionStyle;
use super::effort_status_line::EFFORT_STATUS_LINE_FRAME_TICK;
use super::effort_status_line::EffortStatusLineTransition;
use super::file_search_popup::FileSearchPopup;
use super::footer::CollaborationModeIndicator;
use super::footer::FooterKeyHints;
@@ -441,6 +459,11 @@ pub(crate) struct ChatComposer {
footer: FooterState,
has_focus: bool,
frame_requester: Option<FrameRequester>,
effort_tier: Option<EffortTier>,
effort_animation_style: Option<IgnitionStyle>,
effort_ignition: Option<EffortIgnition>,
effort_status_line_transition: Option<EffortStatusLineTransition>,
effort_observed: bool,
attachments: AttachmentState,
placeholder_text: String,
blocks_direct_input: bool,
@@ -617,6 +640,11 @@ impl ChatComposer {
},
has_focus: has_input_focus,
frame_requester: None,
effort_tier: None,
effort_animation_style: None,
effort_ignition: None,
effort_status_line_transition: None,
effort_observed: false,
attachments: AttachmentState::default(),
placeholder_text,
blocks_direct_input: false,
@@ -659,6 +687,55 @@ impl ChatComposer {
self.frame_requester = Some(frame_requester);
}
/// Records the effective reasoning tier, captures the outgoing status
/// line, and queues the one-shot effects for a genuine Max/Ultra change
/// after the initial baseline.
pub(crate) fn set_active_reasoning_effort(
&mut self,
effort: Option<&ReasoningEffort>,
animations_enabled: bool,
) -> bool {
let tier = EffortTier::from_effort(effort);
let is_baseline = !self.effort_observed;
self.effort_observed = true;
if self.effort_tier == tier {
return false;
}
self.effort_tier = tier;
self.effort_ignition = None;
self.effort_status_line_transition = None;
if let Some(tier) = tier
&& !is_baseline
&& animations_enabled
{
let style = IgnitionStyle::random(self.effort_animation_style);
self.effort_ignition = Some(EffortIgnition::new(tier, style));
self.effort_animation_style = Some(style);
if self.footer.status_line_enabled
&& !self.footer.plan_mode_nudge_visible
&& let Some(previous) = passive_footer_status_line(&self.footer_props())
{
self.effort_status_line_transition =
Some(EffortStatusLineTransition::new(tier, previous));
}
if let Some(frame_requester) = &self.frame_requester {
frame_requester.schedule_frame();
}
}
true
}
/// Establishes the current tier without retaining or starting a one-shot effect.
pub(crate) fn set_active_reasoning_effort_baseline(
&mut self,
effort: Option<&ReasoningEffort>,
) {
self.effort_tier = EffortTier::from_effort(effort);
self.effort_observed = true;
self.effort_ignition = None;
self.effort_status_line_transition = None;
}
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.skills = skills;
self.sync_popups();
@@ -4405,6 +4482,25 @@ impl ChatComposer {
} else {
None
};
let transition_visible = status_line_active
&& !self.footer.flash_visible()
&& self.footer.hint_override.is_none();
let transition_active = transition_visible
&& self
.effort_status_line_transition
.as_ref()
.is_some_and(|transition| !transition.is_finished());
let combined_status_line = if transition_visible
&& let Some(transition) = &self.effort_status_line_transition
&& !transition.is_finished()
{
transition.render_line(
combined_status_line.as_ref(),
hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16),
)
} else {
combined_status_line
};
let mut truncated_status_line = if status_line_active {
combined_status_line.as_ref().map(|line| {
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
@@ -4445,6 +4541,8 @@ impl ChatComposer {
Some(side_conversation_context_line(label))
} else if let Some(line) = self.shell_mode_footer_line() {
Some(line)
} else if transition_active {
None
} else if status_line_active {
let full = self.mode_indicator_line(show_cycle_hint);
let compact = self.mode_indicator_line(/*show_cycle_hint*/ false);
@@ -4575,6 +4673,13 @@ impl ChatComposer {
{
mark_underlined_hyperlink(buf, hint_rect, url);
}
if transition_visible
&& let Some(transition) = &self.effort_status_line_transition
&& !transition.is_finished()
&& let Some(frame_requester) = &self.frame_requester
{
frame_requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
}
}
}
}
@@ -4589,6 +4694,13 @@ impl ChatComposer {
let prompt = if self.draft.input_enabled {
if self.draft.is_bash_mode {
Span::from("!").light_red().bold()
} else if let Some(tier) = self.effort_tier {
let charge = self
.effort_ignition
.as_ref()
.map(EffortIgnition::charge_alpha)
.unwrap_or(1.0);
tier.prompt(charge)
} else {
"›".bold()
}
@@ -4653,9 +4765,34 @@ impl ChatComposer {
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
if matches!(self.popups.active, ActivePopup::None)
&& let Some(ignition) = &self.effort_ignition
&& !ignition.is_finished()
{
let protected_top = if remote_images_rect.is_empty() {
textarea_rect.y
} else {
remote_images_rect.y
};
let protected = Rect::new(
composer_rect.x,
protected_top,
composer_rect.width,
textarea_rect.bottom().saturating_sub(protected_top),
);
if ignition.render(composer_rect, protected, buf)
&& let Some(frame_requester) = &self.frame_requester
{
frame_requester.schedule_frame_in(IGNITION_FRAME_TICK);
}
}
}
}
#[cfg(test)]
#[path = "chat_composer_effort_tests.rs"]
mod effort_tests;
#[cfg(test)]
mod tests {
use super::attachment_state::AttachedImage;
@@ -4679,7 +4816,7 @@ mod tests {
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
fn new_test_composer() -> (ChatComposer, UnboundedReceiver<AppEvent>) {
pub(super) fn new_test_composer() -> (ChatComposer, UnboundedReceiver<AppEvent>) {
let (tx, rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
(
@@ -4867,7 +5004,7 @@ mod tests {
);
}
fn snapshot_composer_state_with_width<F>(
pub(super) fn snapshot_composer_state_with_width<F>(
name: &str,
width: u16,
enhanced_keys_supported: bool,
@@ -0,0 +1,191 @@
use super::tests::new_test_composer;
use super::tests::snapshot_composer_state_with_width;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn effort_composer_baseline_repeat_and_lowering_do_not_replay() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_none());
assert!(!composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_animation_style.is_some());
assert!(composer.effort_status_line_transition.is_some());
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_status_line_transition.is_some());
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Medium),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_none());
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_transition_does_not_queue_a_missing_outgoing_status_line() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
composer.set_task_running(/*running*/ true);
composer.set_text_content("queued draft".to_string(), Vec::new(), Vec::new());
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ true,
);
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_transition_does_not_queue_while_plan_mode_nudge_is_visible() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
composer.set_plan_mode_nudge_visible(/*visible*/ true);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ true,
);
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_composer_restored_baseline_and_reduced_motion_do_not_start() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ false,
);
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ false,
));
assert_eq!(composer.effort_tier, Some(EffortTier::Ultra));
assert!(composer.effort_ignition.is_none());
assert_eq!(composer.effort_animation_style, None);
assert!(composer.effort_status_line_transition.is_none());
assert!(composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_some());
assert!(composer.effort_status_line_transition.is_some());
composer.set_active_reasoning_effort_baseline(Some(&ReasoningEffort::Max));
assert_eq!(composer.effort_tier, Some(EffortTier::Max));
assert!(composer.effort_ignition.is_none());
assert!(composer.effort_status_line_transition.is_none());
assert!(!composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
/*animations_enabled*/ true,
));
assert!(composer.effort_ignition.is_none());
assert!(composer.effort_status_line_transition.is_none());
}
#[test]
fn effort_transition_never_replaces_a_footer_flash() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_status_line(Some(Line::from("gpt-5.4 high · main")));
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ true,
);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
);
composer.set_status_line(Some(Line::from("gpt-5.4 ultra · main")));
composer
.footer
.show_flash(Line::from("saved"), Duration::from_secs(/*secs*/ 1));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 6,
);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let footer = (0..area.width)
.map(|column| buf[(column, area.bottom() - 1)].symbol())
.collect::<String>();
assert!(footer.contains("saved"));
assert!(!footer.contains("U L T R A"));
}
#[test]
fn effort_transition_keeps_the_full_footer_row() {
let (mut composer, _rx) = new_test_composer();
composer.set_status_line_enabled(/*enabled*/ true);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_collaboration_mode_indicator(Some(CollaborationModeIndicator::Plan));
composer.set_status_line(Some(Line::from("gpt-5.4 high · feature-branch")));
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::High),
/*animations_enabled*/ true,
);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 36, /*height*/ 6,
);
let mut buf = Buffer::empty(area);
composer.render(area, &mut buf);
let footer = (0..area.width)
.map(|column| buf[(column, area.bottom() - 1)].symbol())
.collect::<String>();
assert!(footer.contains("gpt-5.4 high · feature-branch"));
assert!(!footer.contains("Plan mode"));
insta::assert_snapshot!("effort_transition_keeps_the_full_footer_row", footer);
}
#[test]
fn ultra_accent_upgrades_prompt_glyph() {
snapshot_composer_state_with_width(
"ultra_accent_upgrades_prompt_glyph",
/*width*/ 60,
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Ultra),
/*animations_enabled*/ true,
);
},
);
}
@@ -0,0 +1,254 @@
//! One-shot celebration animations shown inside the composer band when the
//! active reasoning effort changes to Max or Ultra, plus the persistent
//! prompt accent that remains while one of those tiers is active.
//!
//! Wave, Aurora, and Pulse are text-safe: backgrounds blend underneath the
//! draft and glyphs only land in empty cells. Real tier changes choose a
//! random style without repeating the previous one.
//!
//! The clock starts on the first rendered frame. Browser-backed and native
//! terminals without reliable ANSI-256/truecolor support, and sessions with
//! animations disabled, remain static.
use std::cell::Cell;
use std::time::Duration;
use std::time::Instant;
use codex_protocol::openai_models::ReasoningEffort;
use rand::Rng as _;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use crate::color::blend;
use crate::color::is_light;
use crate::style::user_message_bg_rgb;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color_for_level;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use crate::terminal_palette::effective_stdout_color_level;
#[path = "effort_ignition_styles.rs"]
mod styles;
use styles::Canvas;
use styles::paint_style;
const PROMPT_ACCENT_ALPHA: f32 = 0.86;
const CHARGE: Duration = Duration::from_millis(150);
pub(crate) const IGNITION_FRAME_TICK: Duration = Duration::from_millis(33);
pub(crate) fn effort_animation_enabled(
animations_enabled: bool,
color_level: StdoutColorLevel,
) -> bool {
animations_enabled
&& matches!(
color_level,
StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EffortTier {
Max,
Ultra,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum IgnitionStyle {
Wave,
Aurora,
Pulse,
}
impl IgnitionStyle {
const ALL: [Self; 3] = [Self::Wave, Self::Aurora, Self::Pulse];
pub(crate) fn random(previous: Option<Self>) -> Self {
let mut rng = rand::rng();
loop {
let style = Self::ALL[rng.random_range(0..Self::ALL.len())];
if Some(style) != previous {
return style;
}
}
}
fn total_duration(self, tier: EffortTier) -> Duration {
let (max, ultra) = match self {
Self::Wave => (1000, 1300),
Self::Aurora => (1300, 1600),
Self::Pulse => (900, 1250),
};
Duration::from_millis(match tier {
EffortTier::Max => max,
EffortTier::Ultra => ultra,
})
}
}
impl EffortTier {
pub(crate) fn from_effort(effort: Option<&ReasoningEffort>) -> Option<Self> {
match effort {
Some(ReasoningEffort::Max) => Some(Self::Max),
Some(ReasoningEffort::Ultra) => Some(Self::Ultra),
_ => None,
}
}
fn prompt_glyph(self) -> &'static str {
match self {
Self::Max => "›",
Self::Ultra => "»",
}
}
pub(super) fn hues(self, on_light_bg: bool) -> [(u8, u8, u8); 3] {
match (self, on_light_bg) {
(Self::Max, false) => [(255, 178, 66), (255, 214, 120), (255, 120, 60)],
(Self::Max, true) => [(176, 98, 0), (150, 110, 0), (200, 70, 20)],
(Self::Ultra, false) => [(186, 130, 255), (255, 120, 220), (120, 170, 255)],
(Self::Ultra, true) => [(124, 58, 217), (190, 40, 150), (30, 100, 220)],
}
}
pub(super) fn accent_rgb(self, on_light_bg: bool) -> (u8, u8, u8) {
self.hues(on_light_bg)[0]
}
fn accent_fallback(self) -> Color {
match self {
Self::Max => Color::Yellow,
Self::Ultra => Color::Magenta,
}
}
pub(crate) fn prompt(self, charge: f32) -> Span<'static> {
self.prompt_for(
charge,
default_fg(),
default_bg(),
effective_stdout_color_level(),
)
}
fn prompt_for(
self,
charge: f32,
terminal_fg: Option<(u8, u8, u8)>,
terminal_bg: Option<(u8, u8, u8)>,
color_level: StdoutColorLevel,
) -> Span<'static> {
let color = self.accent_color_for(charge, terminal_fg, terminal_bg, color_level);
let mut style = Style::default().add_modifier(Modifier::BOLD);
if let Some(color) = color {
style = style.fg(color);
}
Span::styled(self.prompt_glyph(), style)
}
fn accent_color_for(
self,
charge: f32,
terminal_fg: Option<(u8, u8, u8)>,
terminal_bg: Option<(u8, u8, u8)>,
color_level: StdoutColorLevel,
) -> Option<Color> {
let on_light_bg = terminal_bg.is_some_and(is_light);
let accent = self.accent_rgb(on_light_bg);
let target = match terminal_fg {
Some(fg) => blend(accent, fg, charge.clamp(0.0, 1.0) * PROMPT_ACCENT_ALPHA),
None => accent,
};
match color_level {
StdoutColorLevel::TrueColor | StdoutColorLevel::Ansi256 => {
Some(best_color_for_level(target, color_level))
}
StdoutColorLevel::Ansi16 => Some(self.accent_fallback()),
StdoutColorLevel::Unknown => None,
}
}
}
pub(crate) struct EffortIgnition {
tier: EffortTier,
style: IgnitionStyle,
started_at: Cell<Option<Instant>>,
cancelled: Cell<bool>,
}
impl EffortIgnition {
pub(crate) fn new(tier: EffortTier, style: IgnitionStyle) -> Self {
Self {
tier,
style,
started_at: Cell::new(/*value*/ None),
cancelled: Cell::new(/*value*/ false),
}
}
fn elapsed(&self) -> Option<Duration> {
self.started_at.get().map(|started| started.elapsed())
}
pub(crate) fn is_finished(&self) -> bool {
self.cancelled.get()
|| self
.elapsed()
.is_some_and(|elapsed| elapsed >= self.style.total_duration(self.tier))
}
pub(crate) fn charge_alpha(&self) -> f32 {
match self.elapsed() {
Some(elapsed) => (elapsed.as_secs_f32() / CHARGE.as_secs_f32()).clamp(0.0, 1.0),
None => 0.0,
}
}
pub(crate) fn render(&self, area: Rect, protected: Rect, buf: &mut Buffer) -> bool {
if area.is_empty() {
return false;
}
let color_level = effective_stdout_color_level();
if !effort_animation_enabled(/*animations_enabled*/ true, color_level) {
self.cancelled.set(/*val*/ true);
return false;
}
let Some(term_bg) = default_bg() else {
self.cancelled.set(/*val*/ true);
return false;
};
let elapsed = match self.started_at.get() {
Some(started) => started.elapsed(),
None => {
self.started_at.set(Some(Instant::now()));
Duration::ZERO
}
};
let mut canvas = Canvas {
area,
protected,
buf,
band_rgb: user_message_bg_rgb(term_bg),
color_level,
};
paint_style(
self.tier,
self.style,
elapsed,
self.tier.hues(is_light(term_bg)),
&mut canvas,
);
true
}
}
#[cfg(test)]
#[path = "effort_ignition_tests.rs"]
mod tests;
@@ -0,0 +1,238 @@
use std::time::Duration;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use crate::color::blend;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::best_color_for_level;
use super::EffortTier;
use super::IgnitionStyle;
const WAVE_HALF_WIDTH: f32 = 9.0;
const PULSE_HALF_WIDTH: f32 = 4.5;
const SPARK_START: Duration = Duration::from_millis(900);
const SPARK_FRAME: Duration = Duration::from_millis(100);
const SPARK_GLYPHS: &[&str] = &["·", "✦", "✧"];
/// Band entries are `(launch_or_speed, travel_or_phase, strength_or_hue)`.
type Band = (f32, f32, f32);
fn bands(style: IgnitionStyle, tier: EffortTier) -> &'static [Band] {
match (style, tier) {
(IgnitionStyle::Wave, EffortTier::Max) => &[(0.10, 0.75, 1.0)],
(IgnitionStyle::Wave, EffortTier::Ultra) => &[(0.10, 0.70, 1.0), (0.35, 0.55, 1.0)],
(IgnitionStyle::Aurora, EffortTier::Max) => &[(0.35, 0.15, 0.0), (-0.50, 0.60, 1.0)],
(IgnitionStyle::Aurora, EffortTier::Ultra) => {
&[(0.35, 0.15, 0.0), (-0.50, 0.60, 1.0), (0.75, 0.35, 2.0)]
}
(IgnitionStyle::Pulse, EffortTier::Max) => &[(0.10, 0.60, 1.0)],
(IgnitionStyle::Pulse, EffortTier::Ultra) => &[(0.10, 0.55, 0.8), (0.45, 0.55, 1.1)],
}
}
/// Paint target clipped to the composer band. Backgrounds blend from the band
/// tint; glyphs only land outside the protected draft and attachment rows.
pub(super) struct Canvas<'a> {
pub(super) area: Rect,
pub(super) protected: Rect,
pub(super) buf: &'a mut Buffer,
pub(super) band_rgb: (u8, u8, u8),
pub(super) color_level: StdoutColorLevel,
}
impl Canvas<'_> {
fn tint(&mut self, x: u16, y: u16, hue: (u8, u8, u8), alpha: f32) {
if alpha < 0.02 || x >= self.area.width || y >= self.area.height {
return;
}
let color = best_color_for_level(
blend(hue, self.band_rgb, alpha.clamp(0.0, 0.6)),
self.color_level,
);
if color != Color::default() {
self.buf[(self.area.x + x, self.area.y + y)].set_bg(color);
}
}
fn tint_column(&mut self, x: u16, hue: (u8, u8, u8), alpha: f32) {
for y in 0..self.area.height {
self.tint(x, y, hue, alpha);
}
}
fn glyph(&mut self, x: u16, y: u16, glyph: &'static str, hue: (u8, u8, u8), strength: f32) {
if x >= self.area.width || y >= self.area.height {
return;
}
let x = self.area.x + x;
let y = self.area.y + y;
if self.protected.contains((x, y).into()) || self.buf[(x, y)].symbol() != " " {
return;
}
let color = best_color_for_level(
blend(hue, self.band_rgb, strength.clamp(0.25, 1.0)),
self.color_level,
);
if color != Color::default() {
self.buf[(x, y)]
.set_symbol(glyph)
.set_style(Style::default().fg(color).add_modifier(Modifier::BOLD));
}
}
}
fn crest(distance: f32) -> f32 {
if distance >= 1.0 {
0.0
} else {
0.5 * (1.0 + (std::f32::consts::PI * distance).cos())
}
}
fn ease_in_out(progress: f32) -> f32 {
let progress = progress.clamp(0.0, 1.0);
if progress < 0.5 {
4.0 * progress * progress * progress
} else {
let inverse = -2.0 * progress + 2.0;
1.0 - inverse * inverse * inverse / 2.0
}
}
pub(super) fn envelope(elapsed: f32, total: f32, fade_in: f32, fade_out: f32) -> f32 {
if elapsed <= 0.0 || elapsed >= total {
return 0.0;
}
(elapsed / fade_in.max(f32::EPSILON))
.min((total - elapsed) / fade_out.max(f32::EPSILON))
.clamp(0.0, 1.0)
}
fn band_sample(
style: IgnitionStyle,
band: &Band,
elapsed: f32,
column: u16,
width: u16,
) -> (usize, f32) {
let column = f32::from(column);
let width = f32::from(width);
let (first, second, third) = *band;
match style {
IgnitionStyle::Wave => {
let (launch, travel) = (first, second);
let progress = (elapsed - launch) / travel;
if !(0.0..=1.0).contains(&progress) {
return (0, 0.0);
}
let center = ease_in_out(progress) * (width + 2.0 * WAVE_HALF_WIDTH) - WAVE_HALF_WIDTH;
(0, crest((column - center).abs() / WAVE_HALF_WIDTH))
}
IgnitionStyle::Aurora => {
let (speed, phase, hue) = (first, second, third as usize);
let center =
(0.5 + 0.38 * (std::f32::consts::TAU * (speed * elapsed + phase)).sin()) * width;
let half_width = (width * 0.22).max(4.0);
(hue, crest((column - center).abs() / half_width))
}
IgnitionStyle::Pulse => {
let (launch, travel, strength) = (first, second, third);
let progress = (elapsed - launch) / travel;
if !(0.0..=1.0).contains(&progress) {
return (0, 0.0);
}
let inverse = 1.0 - progress;
let radius =
(1.0 - inverse * inverse * inverse) * (width / 2.0 + 2.0 * PULSE_HALF_WIDTH);
let distance = (column - width / 2.0).abs();
(
0,
crest((distance - radius).abs() / PULSE_HALF_WIDTH)
* strength
* (1.0 - 0.6 * progress),
)
}
}
}
pub(super) fn spark_frame(elapsed: Duration, start: Duration) -> Option<&'static str> {
let frame = elapsed.checked_sub(start)?.as_millis() / SPARK_FRAME.as_millis();
SPARK_GLYPHS.get(frame as usize).copied()
}
fn paint_bands(
tier: EffortTier,
style: IgnitionStyle,
elapsed: Duration,
hues: [(u8, u8, u8); 3],
canvas: &mut Canvas<'_>,
) {
let elapsed = elapsed.as_secs_f32();
let total = style.total_duration(tier).as_secs_f32();
let fade = if style == IgnitionStyle::Aurora {
envelope(
elapsed, total, /*fade_in*/ 0.25, /*fade_out*/ 0.40,
)
} else {
1.0
};
for column in 0..canvas.area.width {
let mut weights = [0.0_f32; 3];
for band in bands(style, tier) {
let (hue, strength) = band_sample(style, band, elapsed, column, canvas.area.width);
weights[hue] = if style == IgnitionStyle::Aurora {
weights[hue] + strength
} else {
weights[hue].max(strength)
};
}
let weight = weights.iter().sum::<f32>();
if weight <= 0.01 {
continue;
}
let mut rgb = [0.0_f32; 3];
for (weight, (red, green, blue)) in weights.into_iter().zip(hues) {
rgb[0] += weight * f32::from(red);
rgb[1] += weight * f32::from(green);
rgb[2] += weight * f32::from(blue);
}
let hue = (
(rgb[0] / weight) as u8,
(rgb[1] / weight) as u8,
(rgb[2] / weight) as u8,
);
let alpha = if style == IgnitionStyle::Aurora {
(weight * 0.40).min(0.50) * fade
} else {
weight * 0.55
};
canvas.tint_column(column, hue, alpha);
}
}
pub(super) fn paint_style(
tier: EffortTier,
style: IgnitionStyle,
elapsed: Duration,
hues: [(u8, u8, u8); 3],
canvas: &mut Canvas<'_>,
) {
paint_bands(tier, style, elapsed, hues, canvas);
if style == IgnitionStyle::Wave
&& tier == EffortTier::Ultra
&& let Some(glyph) = spark_frame(elapsed, SPARK_START)
{
canvas.glyph(
canvas.area.width.saturating_sub(2),
/*y*/ 0,
glyph,
hues[0],
/*strength*/ 1.0,
);
}
}
@@ -0,0 +1,344 @@
use super::styles::envelope;
use super::styles::paint_style;
use super::styles::spark_frame;
use super::*;
use crate::terminal_palette::rgb_color;
use pretty_assertions::assert_eq;
use ratatui::widgets::Widget;
const WIDTH: u16 = 44;
const HEIGHT: u16 = 3;
const DRAFT: &str = " > keep my draft exactly as typed";
fn test_buffer(area: Rect) -> Buffer {
let mut buf = Buffer::empty(area);
if area.height > 1 {
for (column, glyph) in DRAFT.chars().take(usize::from(area.width)).enumerate() {
buf[(area.x + column as u16, area.y + 1)].set_symbol(&glyph.to_string());
}
}
buf
}
fn frame(area: Rect, buf: &Buffer) -> Vec<String> {
let symbols = (area.y..area.bottom()).map(|row| {
(area.x..area.right())
.map(|column| buf[(column, row)].symbol())
.collect::<String>()
});
let tint = (area.y..area.bottom()).map(|row| {
(area.x..area.right())
.map(|column| {
if buf[(column, row)].bg == Color::Reset {
'.'
} else {
'#'
}
})
.collect::<String>()
});
symbols.chain(tint).collect()
}
fn style_name(style: IgnitionStyle) -> &'static str {
match style {
IgnitionStyle::Wave => "wave",
IgnitionStyle::Aurora => "aurora",
IgnitionStyle::Pulse => "pulse",
}
}
fn paint(tier: EffortTier, style: IgnitionStyle, elapsed: Duration, area: Rect, buf: &mut Buffer) {
let term_bg = (18, 22, 28);
let mut canvas = Canvas {
area,
protected: Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height.saturating_sub(1).min(1),
),
buf,
band_rgb: user_message_bg_rgb(term_bg),
color_level: StdoutColorLevel::TrueColor,
};
paint_style(
tier,
style,
elapsed,
tier.hues(/*on_light_bg*/ false),
&mut canvas,
);
}
#[test]
fn effort_tier_maps_only_max_and_ultra() {
assert_eq!(
[
Some(&ReasoningEffort::Max),
Some(&ReasoningEffort::Ultra),
Some(&ReasoningEffort::XHigh),
None,
]
.map(EffortTier::from_effort),
[Some(EffortTier::Max), Some(EffortTier::Ultra), None, None]
);
}
#[test]
fn effort_animation_requires_motion_and_a_reliable_palette() {
for (color_level, supported) in [
(StdoutColorLevel::TrueColor, true),
(StdoutColorLevel::Ansi256, true),
(StdoutColorLevel::Ansi16, false),
(StdoutColorLevel::Unknown, false),
] {
assert_eq!(
effort_animation_enabled(/*animations_enabled*/ true, color_level),
supported
);
assert!(!effort_animation_enabled(
/*animations_enabled*/ false,
color_level,
));
}
}
#[test]
fn prompt_accent_blends_with_the_terminal_foreground() {
for (tier, fg, bg, expected) in [
(
EffortTier::Ultra,
(224, 220, 214),
(18, 22, 28),
(191, 142, 249),
),
(EffortTier::Max, (30, 32, 36), (250, 248, 244), (155, 88, 5)),
] {
assert_eq!(
tier.accent_color_for(
/*charge*/ 1.0,
Some(fg),
Some(bg),
StdoutColorLevel::TrueColor,
),
Some(rgb_color(expected))
);
}
}
#[test]
fn prompt_accent_degrades_with_terminal_color_support() {
let fg = (224, 220, 214);
let bg = (18, 22, 28);
assert!(matches!(
EffortTier::Ultra.accent_color_for(
/*charge*/ 1.0,
Some(fg),
Some(bg),
StdoutColorLevel::Ansi256,
),
Some(Color::Indexed(_))
));
for (tier, color_level, expected) in [
(
EffortTier::Max,
StdoutColorLevel::Ansi16,
Some(Color::Yellow),
),
(
EffortTier::Ultra,
StdoutColorLevel::Ansi16,
Some(Color::Magenta),
),
(EffortTier::Ultra, StdoutColorLevel::Unknown, None),
] {
assert_eq!(
tier.accent_color_for(/*charge*/ 1.0, Some(fg), Some(bg), color_level),
expected
);
}
}
#[test]
fn max_and_ultra_prompts_render_their_accent_and_glyph() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 1,
);
for (tier, glyph, color) in [
(EffortTier::Max, "›", Color::Yellow),
(EffortTier::Ultra, "»", Color::Magenta),
] {
let mut buf = Buffer::empty(area);
tier.prompt_for(
/*charge*/ 1.0,
Some((224, 220, 214)),
Some((18, 22, 28)),
StdoutColorLevel::Ansi16,
)
.render(area, &mut buf);
let prompt = &buf[(0, 0)];
assert_eq!(prompt.symbol(), glyph);
assert_eq!(prompt.style().fg, Some(color));
assert!(prompt.style().add_modifier.contains(Modifier::BOLD));
}
}
#[test]
fn effort_ignition_clock_waits_for_its_first_visible_frame() {
let ignition = EffortIgnition::new(EffortTier::Ultra, IgnitionStyle::Wave);
let area = Rect::default();
let mut buf = Buffer::empty(area);
assert!(!ignition.render(area, area, &mut buf));
assert_eq!(ignition.started_at.get(), None);
assert!(!ignition.is_finished());
assert_eq!(ignition.charge_alpha(), 0.0);
}
#[cfg(unix)]
#[test]
fn effort_ignition_finishes_when_palette_is_unavailable() {
let ignition = EffortIgnition::new(EffortTier::Ultra, IgnitionStyle::Wave);
let area = Rect {
width: 1,
height: 1,
..Rect::default()
};
let mut buf = Buffer::empty(area);
assert!(!ignition.render(area, area, &mut buf));
assert_eq!(ignition.started_at.get(), None);
assert!(ignition.is_finished());
}
#[test]
fn effort_ignition_random_styles_do_not_repeat_immediately() {
let mut previous = None;
for _ in 0..100 {
let style = IgnitionStyle::random(previous);
assert_ne!(Some(style), previous);
previous = Some(style);
}
}
#[test]
fn envelope_is_zero_outside_and_full_in_the_middle() {
assert_eq!(
[0.0, 1.0, 0.5].map(|elapsed| {
envelope(
elapsed, /*total*/ 1.0, /*fade_in*/ 0.2, /*fade_out*/ 0.2,
)
}),
[0.0, 0.0, 1.0]
);
}
#[test]
fn wave_only_paints_while_a_sweep_is_crossing_the_composer() {
let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT);
for (millis, expected) in [(0, false), (475, true), (3000, false)] {
let mut buf = test_buffer(area);
paint(
EffortTier::Max,
IgnitionStyle::Wave,
Duration::from_millis(millis),
area,
&mut buf,
);
let painted = frame(area, &buf)[HEIGHT as usize..]
.iter()
.any(|row| row.contains('#'));
assert_eq!(painted, expected);
}
}
#[test]
fn spark_only_fires_after_landing() {
let start = Duration::from_millis(900);
assert_eq!(
[850, 950, 1050, 1150, 1250]
.map(|millis| spark_frame(Duration::from_millis(millis), start)),
[None, Some("·"), Some("✦"), Some("✧"), None]
);
}
#[test]
fn effort_ignition_styles_preserve_draft_and_paint_expected_content() {
let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT);
for style in IgnitionStyle::ALL {
let mut painted = false;
for millis in [180, 420, 720, 1100, 1580] {
let mut buf = test_buffer(area);
paint(
EffortTier::Ultra,
style,
Duration::from_millis(millis),
area,
&mut buf,
);
let rendered = frame(area, &buf);
assert!(rendered[1].starts_with(DRAFT));
painted |= rendered[HEIGHT as usize..]
.iter()
.any(|row| row.contains('#'))
|| rendered[..HEIGHT as usize]
.iter()
.any(|row| row.contains(['✦', '✧', '·']));
}
assert!(
painted,
"{} never painted visible content",
style_name(style)
);
}
}
#[test]
fn effort_ignition_styles_are_safe_on_tiny_and_offset_areas() {
for style in IgnitionStyle::ALL {
for width in 0..=8 {
for height in 0..=3 {
let area = Rect::new(/*x*/ 2, /*y*/ 4, width, height);
let mut buf = Buffer::empty(Rect::new(
/*x*/ 0,
/*y*/ 0,
width.saturating_add(4),
height.saturating_add(8),
));
for millis in [0, 180, 720, 1450, 2350] {
paint(
EffortTier::Max,
style,
Duration::from_millis(millis),
area,
&mut buf,
);
}
}
}
}
}
#[test]
fn effort_ignition_animation_gallery_snapshot() {
let area = Rect::new(/*x*/ 0, /*y*/ 0, WIDTH, HEIGHT);
let mut frames = Vec::new();
for style in IgnitionStyle::ALL {
for tier in [EffortTier::Max, EffortTier::Ultra] {
let tier_name = match tier {
EffortTier::Max => "MAX",
EffortTier::Ultra => "ULTRA",
};
for millis in [0, 180, 420, 720, 1100, 1580, 2050] {
let mut buf = test_buffer(area);
paint(tier, style, Duration::from_millis(millis), area, &mut buf);
let name = style_name(style);
let rendered = frame(area, &buf).join("│");
frames.push(format!("{name:8} {tier_name:5} {millis:4}ms │{rendered}│"));
}
}
}
insta::assert_snapshot!("effort_ignition_animation_gallery", frames.join("\n"));
}
@@ -0,0 +1,254 @@
//! One-shot status-line transition shown when reasoning effort changes to Max
//! or Ultra.
//!
//! The previous status line slides to the right while it picks up the tier
//! accent and fades away. The tier letters then appear across the row with
//! wider peripheral gaps, converge into a centered `M A X` or `U L T R A`, and
//! fade out before the refreshed status line fades in. The clock starts only
//! when the passive footer row is rendered, so a picker, flash, or
//! instructional footer cannot consume the animation before it becomes
//! visible. ANSI-16 and unknown-color terminals skip the transition and keep
//! the normal status line; browser-backed and native terminals with ANSI-256
//! or truecolor can show the full effect.
use std::cell::Cell;
use std::time::Duration;
use std::time::Instant;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use crate::color::blend;
use crate::color::is_light;
use crate::line_truncation::truncate_line_to_width;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use super::effort_ignition::EffortTier;
const SCROLL_OUT: Duration = Duration::from_millis(620);
const LABEL_ASSEMBLE: Duration = Duration::from_millis(700);
const LABEL_HOLD: Duration = Duration::from_millis(360);
const LABEL_FADE_OUT: Duration = Duration::from_millis(340);
const STATUS_FADE_IN: Duration = Duration::from_millis(480);
const TOTAL: Duration = SCROLL_OUT
.saturating_add(LABEL_ASSEMBLE)
.saturating_add(LABEL_HOLD)
.saturating_add(LABEL_FADE_OUT)
.saturating_add(STATUS_FADE_IN);
pub(crate) const EFFORT_STATUS_LINE_FRAME_TICK: Duration = Duration::from_millis(33);
impl EffortTier {
fn label(self) -> &'static str {
match self {
Self::Max => "MAX",
Self::Ultra => "ULTRA",
}
}
fn fallback_color(self) -> Color {
match self {
Self::Max => Color::Yellow,
Self::Ultra => Color::Magenta,
}
}
}
pub(crate) struct EffortStatusLineTransition {
tier: EffortTier,
previous: Line<'static>,
started_at: Cell<Option<Instant>>,
}
impl EffortStatusLineTransition {
pub(crate) fn new(tier: EffortTier, previous: Line<'static>) -> Self {
Self {
tier,
previous,
started_at: Cell::new(None),
}
}
pub(crate) fn is_finished(&self) -> bool {
self.started_at
.get()
.is_some_and(|started| started.elapsed() >= TOTAL)
}
pub(crate) fn render_line(
&self,
current: Option<&Line<'static>>,
width: u16,
) -> Option<Line<'static>> {
let elapsed = match self.started_at.get() {
Some(started) => started.elapsed(),
None => {
self.started_at.set(Some(Instant::now()));
Duration::ZERO
}
};
transition_line_at(self.tier, Some(&self.previous), current, elapsed, width)
}
}
fn transition_line_at(
tier: EffortTier,
previous: Option<&Line<'static>>,
current: Option<&Line<'static>>,
elapsed: Duration,
width: u16,
) -> Option<Line<'static>> {
let width = usize::from(width);
if width == 0 {
return None;
}
if elapsed < SCROLL_OUT {
let progress = elapsed.as_secs_f32() / SCROLL_OUT.as_secs_f32();
let offset = (width as f32 * ease_in_cubic(progress)).round() as usize;
let mut line = previous.cloned()?;
let tint = 0.12 + 0.88 * progress;
style_line(
&mut line,
tier,
/*opacity*/ 1.0 - progress * progress,
tint,
);
line.spans
.insert(/*index*/ 0, Span::raw(" ".repeat(offset)));
return Some(truncate_line_to_width(line, width));
}
let after_scroll = elapsed.saturating_sub(SCROLL_OUT);
let label_duration = LABEL_ASSEMBLE
.saturating_add(LABEL_HOLD)
.saturating_add(LABEL_FADE_OUT);
if after_scroll < label_duration {
let (assemble, opacity) = if after_scroll < LABEL_ASSEMBLE {
let progress = after_scroll.as_secs_f32() / LABEL_ASSEMBLE.as_secs_f32();
(ease_out_cubic(progress), (progress / 0.55).clamp(0.0, 1.0))
} else if after_scroll < LABEL_ASSEMBLE.saturating_add(LABEL_HOLD) {
(1.0, 1.0)
} else {
let fade = after_scroll.saturating_sub(LABEL_ASSEMBLE.saturating_add(LABEL_HOLD));
(1.0, 1.0 - fade.as_secs_f32() / LABEL_FADE_OUT.as_secs_f32())
};
return Some(tier_label_line(tier, width, assemble, opacity));
}
let fade_elapsed = after_scroll.saturating_sub(label_duration);
let opacity = (fade_elapsed.as_secs_f32() / STATUS_FADE_IN.as_secs_f32()).clamp(0.0, 1.0);
let mut line = current.cloned()?;
style_line(&mut line, tier, opacity, /*tint*/ 0.0);
Some(truncate_line_to_width(line, width))
}
fn ease_in_cubic(progress: f32) -> f32 {
let progress = progress.clamp(0.0, 1.0);
progress * progress * progress
}
fn ease_out_cubic(progress: f32) -> f32 {
let progress = progress.clamp(0.0, 1.0);
let inverse = 1.0 - progress;
1.0 - inverse * inverse * inverse
}
fn tier_label_line(tier: EffortTier, width: usize, assemble: f32, opacity: f32) -> Line<'static> {
let letters = tier.label().chars().collect::<Vec<_>>();
let gap_count = letters.len().saturating_sub(1);
let compact_width = letters.len().saturating_add(gap_count);
let max_extra = width.saturating_sub(compact_width);
let spread = (max_extra as f32 * (1.0 - assemble.clamp(0.0, 1.0))).round() as usize;
let gap_weights = (0..gap_count)
.map(|index| {
let position = index.saturating_mul(2).saturating_add(1);
position.abs_diff(gap_count).saturating_add(1)
})
.collect::<Vec<_>>();
let weight_total = gap_weights.iter().sum::<usize>().max(1);
let gaps = gap_weights
.iter()
.map(|weight| 1 + spread.saturating_mul(*weight) / weight_total)
.collect::<Vec<_>>();
let label_width = letters.len().saturating_add(gaps.iter().sum::<usize>());
let left_padding = width.saturating_sub(label_width) / 2;
let mut spans = vec![Span::raw(" ".repeat(left_padding))];
for (index, letter) in letters.iter().enumerate() {
let mut style = Style::default().add_modifier(Modifier::BOLD);
let center = letters.len().saturating_sub(1);
let edge = if center == 0 {
0.0
} else {
index.saturating_mul(2).abs_diff(center) as f32 / center as f32
};
let stagger = 0.22 * edge;
let letter_opacity = ((opacity - stagger) / (1.0 - stagger)).clamp(0.0, 1.0);
apply_fade(&mut style, tier, letter_opacity, /*tint*/ 1.0);
spans.push(Span::styled(letter.to_string(), style));
if let Some(gap) = gaps.get(index) {
spans.push(Span::raw(" ".repeat(*gap)));
}
}
truncate_line_to_width(Line::from(spans), width)
}
fn style_line(line: &mut Line<'static>, tier: EffortTier, opacity: f32, tint: f32) {
apply_fade(&mut line.style, tier, opacity, tint);
for span in &mut line.spans {
apply_fade(&mut span.style, tier, opacity, tint);
}
}
fn apply_fade(style: &mut Style, tier: EffortTier, opacity: f32, tint: f32) {
let opacity = opacity.clamp(0.0, 1.0);
let tint = tint.clamp(0.0, 1.0);
if matches!(style.fg, Some(color) if !matches!(color, Color::Rgb(..))) {
if opacity < 0.7 {
style.add_modifier |= Modifier::DIM;
}
return;
}
let Some(background) = default_bg() else {
if tint > 0.5 {
style.fg = Some(tier.fallback_color());
}
if opacity < 0.7 {
style.add_modifier |= Modifier::DIM;
}
return;
};
let foreground = match style.fg {
Some(Color::Rgb(r, g, b)) => (r, g, b),
_ => default_fg().unwrap_or_else(|| {
if is_light(background) {
(32, 32, 32)
} else {
(224, 224, 224)
}
}),
};
let accent = tier.accent_rgb(is_light(background));
let tinted = blend(accent, foreground, tint);
let faded = blend(tinted, background, opacity);
let color = best_color(faded);
style.fg = Some(if color == Color::default() {
tier.fallback_color()
} else {
color
});
if opacity < 0.7 {
style.add_modifier |= Modifier::DIM;
}
}
#[cfg(test)]
#[path = "effort_status_line_tests.rs"]
mod tests;
@@ -0,0 +1,188 @@
use super::*;
use crate::terminal_palette::indexed_color;
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
fn text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn clock_waits_for_the_first_visible_frame() {
let transition =
EffortStatusLineTransition::new(EffortTier::Ultra, Line::from("gpt-5.4 high · main"));
assert!(!transition.is_finished());
assert_eq!(transition.started_at.get(), None);
let current = Line::from("gpt-5.4 ultra · main");
let line = transition
.render_line(Some(&current), /*width*/ 40)
.expect("first frame should render the outgoing line");
assert_eq!(text(&line), "gpt-5.4 high · main");
assert!(transition.started_at.get().is_some());
}
#[test]
fn outgoing_status_line_moves_right_and_clips_at_the_edge() {
let previous = Line::from("gpt-5.4 high · main");
let current = Line::from("gpt-5.4 ultra · main");
let first = transition_line_at(
EffortTier::Ultra,
Some(&previous),
Some(&current),
Duration::ZERO,
/*width*/ 28,
)
.expect("outgoing line should be visible");
let middle = transition_line_at(
EffortTier::Ultra,
Some(&previous),
Some(&current),
Duration::from_millis(300),
/*width*/ 28,
)
.expect("outgoing line should be visible");
assert_eq!(text(&first), "gpt-5.4 high · main");
let first_offset = text(&first)
.find("gpt-5.4")
.expect("first frame should contain the outgoing model");
let middle_offset = text(&middle)
.find("gpt-5.4")
.expect("middle frame should contain the outgoing model");
assert!(middle_offset > first_offset);
assert!(middle.width() <= 28);
}
#[test]
fn label_and_refreshed_line_appear_in_order() {
let previous = Line::from("gpt-5.4 high · main");
let current = Line::from("gpt-5.4 max · main");
let label = transition_line_at(
EffortTier::Max,
Some(&previous),
Some(&current),
Duration::from_millis(1500),
/*width*/ 32,
)
.expect("label should be visible");
let refreshed = transition_line_at(
EffortTier::Max,
Some(&previous),
Some(&current),
Duration::from_millis(2200),
/*width*/ 32,
)
.expect("refreshed status line should be visible");
assert_eq!(text(&label), " M A X");
assert_eq!(text(&refreshed), "gpt-5.4 max · main");
}
#[test]
fn unicode_and_span_boundaries_are_safe_on_narrow_rows() {
let previous = Line::from(vec!["模型 ".cyan(), "👩‍💻 main".underlined()]);
let current = Line::from(vec!["模型 ".cyan(), "ultra · main".underlined()]);
for width in 0..=12 {
for elapsed in [
Duration::ZERO,
Duration::from_millis(300),
Duration::from_millis(700),
Duration::from_millis(1450),
Duration::from_millis(2250),
Duration::from_millis(2500),
] {
let line = transition_line_at(
EffortTier::Ultra,
Some(&previous),
Some(&current),
elapsed,
width,
);
assert!(line.is_none_or(|line| line.width() <= usize::from(width)));
}
}
}
#[test]
fn palette_status_line_colors_survive_the_fade() {
for color in [
Color::Cyan,
Color::Green,
Color::Magenta,
indexed_color(/*index*/ 123),
] {
let mut style = Style::default().fg(color);
apply_fade(
&mut style,
EffortTier::Ultra,
/*opacity*/ 0.4,
/*tint*/ 1.0,
);
assert_eq!(style.fg, Some(color));
assert!(style.add_modifier.contains(Modifier::DIM));
}
}
#[test]
fn ultra_letters_start_wide_at_the_edges_and_converge_to_the_center() {
let scattered = tier_label_line(
EffortTier::Ultra,
/*width*/ 40,
/*assemble*/ 0.0,
/*opacity*/ 1.0,
);
let settled = tier_label_line(
EffortTier::Ultra,
/*width*/ 40,
/*assemble*/ 1.0,
/*opacity*/ 1.0,
);
let positions = text(&scattered)
.char_indices()
.filter_map(|(index, letter)| letter.is_ascii_uppercase().then_some(index))
.collect::<Vec<_>>();
let gaps = positions
.windows(2)
.map(|pair| pair[1].saturating_sub(pair[0]))
.collect::<Vec<_>>();
assert_eq!(positions.len(), 5);
assert!(gaps[0] > gaps[1]);
assert!(gaps[3] > gaps[2]);
assert_eq!(text(&settled), " U L T R A");
}
#[test]
fn transition_frames_snapshot() {
let previous = Line::from(vec!["gpt-5.4 high".cyan(), " · main".dim()]);
let max = Line::from(vec!["gpt-5.4 max".cyan(), " · main".dim()]);
let ultra = Line::from(vec!["gpt-5.4 ultra".cyan(), " · main".dim()]);
let mut frames = Vec::new();
for (tier, current) in [(EffortTier::Max, &max), (EffortTier::Ultra, &ultra)] {
for millis in [
0, 300, 520, 680, 820, 1050, 1320, 1550, 1800, 2020, 2250, 2500,
] {
let line = transition_line_at(
tier,
Some(&previous),
Some(current),
Duration::from_millis(millis),
/*width*/ 32,
)
.expect("frame should render");
let label = tier.label();
frames.push(format!("{label:5} {millis:4}ms │{:<32}│", text(&line)));
}
}
insta::assert_snapshot!("effort_status_line_transition_frames", frames.join("\n"));
}
+27
View File
@@ -32,6 +32,7 @@ use crate::keymap::primary_binding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::terminal_palette::effective_stdout_color_level;
use crate::tui::FrameRequester;
pub(crate) use bottom_pane_view::BottomPaneView;
pub(crate) use bottom_pane_view::ViewCompletion;
@@ -41,6 +42,7 @@ use codex_features::Features;
use codex_file_search::FileMatch;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -79,6 +81,7 @@ pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
pub(crate) use request_user_input::RequestUserInputOverlay;
pub(crate) use status_line_style::status_line_from_segments;
mod bottom_pane_view;
mod effort_ignition;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LocalImageAttachment {
@@ -99,6 +102,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
pub(crate) mod custom_prompt_view;
mod effort_status_line;
mod experimental_features_view;
mod file_search_popup;
mod footer;
@@ -318,6 +322,29 @@ impl BottomPane {
self.request_redraw();
}
/// Mirrors the effective reasoning effort into the composer so its next
/// visible frame can play a one-shot Max/Ultra effect.
pub(crate) fn set_active_reasoning_effort(&mut self, effort: Option<&ReasoningEffort>) {
let animations_enabled = effort_ignition::effort_animation_enabled(
self.animations_enabled,
effective_stdout_color_level(),
);
if self
.composer
.set_active_reasoning_effort(effort, animations_enabled)
{
self.request_redraw();
}
}
/// Establishes a restored thread's effort without replaying its one-shot animation.
pub(crate) fn set_active_reasoning_effort_baseline(
&mut self,
effort: Option<&ReasoningEffort>,
) {
self.composer.set_active_reasoning_effort_baseline(effort);
}
pub fn set_connectors_snapshot(&mut self, snapshot: Option<ConnectorsSnapshot>) {
self.composer.set_connector_mentions(snapshot);
self.request_redraw();
@@ -0,0 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer_effort_tests.rs
expression: footer
---
gpt-5.4 high · feature-branch
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"» Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
@@ -0,0 +1,46 @@
---
source: tui/src/bottom_pane/effort_ignition_tests.rs
expression: "frames.join(\"\\n\")"
---
wave MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave MAX 180ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave MAX 420ms │ │ > keep my draft exactly as typed │ │...################.........................│...################.........................│...################.........................│
wave MAX 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave MAX 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave ULTRA 180ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave ULTRA 420ms │ │ > keep my draft exactly as typed │ │.......################.....................│.......################.....................│.......################.....................│
wave ULTRA 720ms │ │ > keep my draft exactly as typed │ │.....................................#######│.....................................#######│.....................................#######│
wave ULTRA 1100ms │ ✧ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave ULTRA 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
wave ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
aurora MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
aurora MAX 180ms │ │ > keep my draft exactly as typed │ │.............#################.#############│.............#################.#############│.............#################.#############│
aurora MAX 420ms │ │ > keep my draft exactly as typed │ │.........................###################│.........................###################│.........................###################│
aurora MAX 720ms │ │ > keep my draft exactly as typed │ │........................####################│........................####################│........................####################│
aurora MAX 1100ms │ │ > keep my draft exactly as typed │ │...........########################.........│...........########################.........│...........########################.........│
aurora MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
aurora MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
aurora ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
aurora ULTRA 180ms │ │ > keep my draft exactly as typed │ │.............###############################│.............###############################│.............###############################│
aurora ULTRA 420ms │ │ > keep my draft exactly as typed │ │################.........###################│################.........###################│################.........###################│
aurora ULTRA 720ms │ │ > keep my draft exactly as typed │ │....################....####################│....################....####################│....################....####################│
aurora ULTRA 1100ms │ │ > keep my draft exactly as typed │ │...........#################################│...........#################################│...........#################################│
aurora ULTRA 1580ms │ │ > keep my draft exactly as typed │ │..##########................................│..##########................................│..##########................................│
aurora ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 180ms │ │ > keep my draft exactly as typed │ │........########.............########.......│........########.............########.......│........########.............########.......│
pulse MAX 420ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 180ms │ │ > keep my draft exactly as typed │ │.......########...............########......│.......########...............########......│.......########...............########......│
pulse ULTRA 420ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
pulse ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│
@@ -0,0 +1,28 @@
---
source: tui/src/bottom_pane/effort_status_line_tests.rs
expression: "frames.join(\"\\n\")"
---
MAX 0ms │gpt-5.4 high · main │
MAX 300ms │ gpt-5.4 high · main │
MAX 520ms │ gpt-5.4 high │
MAX 680ms │ M A X │
MAX 820ms │ M A X │
MAX 1050ms │ M A X │
MAX 1320ms │ M A X │
MAX 1550ms │ M A X │
MAX 1800ms │ M A X │
MAX 2020ms │gpt-5.4 max · main │
MAX 2250ms │gpt-5.4 max · main │
MAX 2500ms │gpt-5.4 max · main │
ULTRA 0ms │gpt-5.4 high · main │
ULTRA 300ms │ gpt-5.4 high · main │
ULTRA 520ms │ gpt-5.4 high │
ULTRA 680ms │ U L T R A │
ULTRA 820ms │ U L T R A │
ULTRA 1050ms │ U L T R A │
ULTRA 1320ms │ U L T R A │
ULTRA 1550ms │ U L T R A │
ULTRA 1800ms │ U L T R A │
ULTRA 2020ms │gpt-5.4 ultra · main │
ULTRA 2250ms │gpt-5.4 ultra · main │
ULTRA 2500ms │gpt-5.4 ultra · main │
@@ -452,6 +452,9 @@ impl ChatWidget {
self.input_queue.clear();
self.restore_composer_state(Default::default());
}
let effort = self.effective_reasoning_effort();
self.bottom_pane
.set_active_reasoning_effort_baseline(effort.as_ref());
self.turn_lifecycle
.restore_running(self.turn_lifecycle.agent_turn_running, Instant::now());
self.update_task_running_state();
@@ -103,6 +103,9 @@ impl ChatWidget {
self.refresh_plan_mode_nudge();
}
}
let effort = self.effective_reasoning_effort();
self.bottom_pane
.set_active_reasoning_effort_baseline(effort.as_ref());
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_service_tier_commands();
+3
View File
@@ -470,6 +470,9 @@ impl ChatWidget {
self.sync_image_paste_enabled();
self.sync_service_tier_commands();
self.refresh_terminal_title();
let effort = self.effective_reasoning_effort();
self.bottom_pane
.set_active_reasoning_effort(effort.as_ref());
}
/// Refresh every UI surface that depends on the effective model, reasoning
+5 -1
View File
@@ -74,12 +74,16 @@ fn table_separator_style_for(
#[allow(clippy::disallowed_methods)]
pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
best_color(user_message_bg_rgb(terminal_bg))
}
pub(crate) fn user_message_bg_rgb(terminal_bg: (u8, u8, u8)) -> (u8, u8, u8) {
let (top, alpha) = if is_light(terminal_bg) {
((0, 0, 0), 0.04)
} else {
((255, 255, 255), 0.12)
};
best_color(blend(top, terminal_bg, alpha))
blend(top, terminal_bg, alpha)
}
#[allow(clippy::disallowed_methods)]
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn best_color_for_level(target: (u8, u8, u8), color_level: StdoutColorLevel)
best_color_for_color_level(target, color_level)
}
fn effective_stdout_color_level() -> StdoutColorLevel {
pub(crate) fn effective_stdout_color_level() -> StdoutColorLevel {
stdout_color_level_for_terminal(
stdout_color_level(),
terminal_info().name,