Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 139 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading