mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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(¤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::<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"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer_effort_tests.rs
|
||||
expression: footer
|
||||
---
|
||||
gpt-5.4 high · feature-branch
|
||||
+13
@@ -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 "
|
||||
+46
@@ -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 │ │............................................│............................................│............................................│
|
||||
+28
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user