diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index cc2a7a996140..d260507ebd90 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, + effort_tier: Option, + effort_animation_style: Option, + effort_ignition: Option, + effort_status_line_transition: Option, + 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>) { 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) { + pub(super) fn new_test_composer() -> (ChatComposer, UnboundedReceiver) { let (tx, rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); ( @@ -4867,7 +5004,7 @@ mod tests { ); } - fn snapshot_composer_state_with_width( + pub(super) fn snapshot_composer_state_with_width( name: &str, width: u16, enhanced_keys_supported: bool, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs new file mode 100644 index 000000000000..437edf879fe6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_effort_tests.rs @@ -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::(); + + 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::(); + + 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, + ); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition.rs b/codex-rs/tui/src/bottom_pane/effort_ignition.rs new file mode 100644 index 000000000000..56658fcaf9a5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition.rs @@ -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 { + 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 { + 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 { + 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>, + cancelled: Cell, +} + +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 { + 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; diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs b/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs new file mode 100644 index 000000000000..86801cd34a39 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition_styles.rs @@ -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::(); + 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, + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs b/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs new file mode 100644 index 000000000000..49b626017fd9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_ignition_tests.rs @@ -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 { + let symbols = (area.y..area.bottom()).map(|row| { + (area.x..area.right()) + .map(|column| buf[(column, row)].symbol()) + .collect::() + }); + let tint = (area.y..area.bottom()).map(|row| { + (area.x..area.right()) + .map(|column| { + if buf[(column, row)].bg == Color::Reset { + '.' + } else { + '#' + } + }) + .collect::() + }); + 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")); +} diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line.rs b/codex-rs/tui/src/bottom_pane/effort_status_line.rs new file mode 100644 index 000000000000..d47605d3c7d7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_status_line.rs @@ -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>, +} + +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> { + 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> { + 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::>(); + 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::>(); + let weight_total = gap_weights.iter().sum::().max(1); + let gaps = gap_weights + .iter() + .map(|weight| 1 + spread.saturating_mul(*weight) / weight_total) + .collect::>(); + let label_width = letters.len().saturating_add(gaps.iter().sum::()); + 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; diff --git a/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs new file mode 100644 index 000000000000..03991e224469 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/effort_status_line_tests.rs @@ -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(¤t), /*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(¤t), + Duration::ZERO, + /*width*/ 28, + ) + .expect("outgoing line should be visible"); + let middle = transition_line_at( + EffortTier::Ultra, + Some(&previous), + Some(¤t), + 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(¤t), + Duration::from_millis(1500), + /*width*/ 32, + ) + .expect("label should be visible"); + let refreshed = transition_line_at( + EffortTier::Max, + Some(&previous), + Some(¤t), + 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(¤t), + 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::>(); + let gaps = positions + .windows(2) + .map(|pair| pair[1].saturating_sub(pair[0])) + .collect::>(); + + 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")); +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ee9b8051c72f..b8d6c9c357e4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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) { self.composer.set_connector_mentions(snapshot); self.request_redraw(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap new file mode 100644 index 000000000000..e02e49dceda8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/chat_composer_effort_tests.rs +expression: footer +--- + gpt-5.4 high · feature-branch diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap new file mode 100644 index 000000000000..1ccc97b273c6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap @@ -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 " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap new file mode 100644 index 000000000000..6d5b27a3752a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap @@ -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 │ │............................................│............................................│............................................│ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap new file mode 100644 index 000000000000..8c02023892cc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap @@ -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 │ diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 1f1e963f593a..101e3ec6bc6c 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -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(); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 0511c248dd6a..52218e022aa5 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -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(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 73dbdb33c8e5..58fe9209053d 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -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 diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 0284df91427b..e0ff5bdf2ae9 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -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)] diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index a602e548c4d1..3c288488e4ab 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -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,