feat(tui/autodrive): add completion celebration state

This commit is contained in:
James Peter
2025-10-27 21:39:28 +10:00
parent dfa65444f6
commit 2130ed295d
7 changed files with 805 additions and 36 deletions
@@ -309,6 +309,7 @@ pub struct AutoDriveController {
pub intro_pending: bool,
pub elapsed_override: Option<Duration>,
pub pending_stop_message: Option<String>,
pub last_completion_explanation: Option<String>,
pub phase: AutoRunPhase,
}
@@ -475,6 +476,7 @@ impl AutoDriveController {
self.started_at = Some(now);
self.turns_completed = 0;
self.last_run_summary = None;
self.last_completion_explanation = None;
self.goal = Some(goal.clone());
self.current_summary = None;
self.current_progress_past = None;
@@ -713,6 +715,7 @@ impl AutoDriveController {
let intro_reduced_motion = self.intro_reduced_motion;
let elapsed_override = self.elapsed_override;
let pending_stop_message = self.pending_stop_message.clone();
let last_completion_explanation = self.last_completion_explanation.clone();
*self = Self::default();
@@ -727,6 +730,7 @@ impl AutoDriveController {
self.intro_reduced_motion = intro_reduced_motion;
self.elapsed_override = elapsed_override;
self.pending_stop_message = pending_stop_message;
self.last_completion_explanation = last_completion_explanation;
self.phase = if self.phase.is_active() {
AutoRunPhase::Active
} else {
+10
View File
@@ -1729,6 +1729,16 @@ impl App<'_> {
widget.handle_terminal_approval_decision(id, approved);
}
}
AppEvent::StartAutoDriveCelebration { message } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.start_auto_drive_card_celebration(message);
}
}
AppEvent::StopAutoDriveCelebration => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.stop_auto_drive_card_celebration();
}
}
AppEvent::TerminalAfter(after) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_terminal_after(after);
+4
View File
@@ -456,6 +456,10 @@ pub(crate) enum AppEvent {
ack: Redacted<StdSender<TerminalCommandGate>>,
},
TerminalApprovalDecision { id: u64, approved: bool },
StartAutoDriveCelebration {
message: Option<String>,
},
StopAutoDriveCelebration,
RunUpdateCommand {
command: Vec<String>,
display: String,
@@ -110,11 +110,6 @@ impl AutoCoordinatorView {
self.model = model;
}
#[cfg(test)]
pub(crate) fn model(&self) -> &AutoCoordinatorViewModel {
&self.model
}
pub fn set_style(&mut self, style: AutoDriveStyle) {
self.style = style;
}
+184 -23
View File
@@ -98,6 +98,7 @@ use code_auto_drive_core::{
AutoCoordinatorStatus,
AutoDriveHistory,
AutoDriveController,
AutoRunSummary,
AutoRunPhase,
AutoControllerEffect,
AutoTurnAgentsAction,
@@ -182,6 +183,8 @@ use crate::chatwidget::message::UserMessage;
pub(crate) const DOUBLE_ESC_HINT: &str = "undo timeline";
const AUTO_ESC_EXIT_HINT: &str = "Press Esc again to exit Auto Drive";
const AUTO_COMPLETION_CELEBRATION_DURATION: Duration = Duration::from_secs(5);
const HISTORY_ANIMATION_FRAME_INTERVAL: Duration = Duration::from_millis(120);
const AUTO_BOOTSTRAP_GOAL_PLACEHOLDER: &str = "Deriving goal from recent conversation";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1913,12 +1916,27 @@ impl ChatWidget<'_> {
) {
let had_tracker = self.tools_state.auto_drive_tracker.is_some();
let order_key = self.auto_card_next_order_key();
auto_drive_cards::finalize(self, order_key, message.clone(), status, kind);
let completion_message = if matches!(status, AutoDriveStatus::Stopped) {
self.auto_state.last_completion_explanation.clone()
} else {
None
};
auto_drive_cards::finalize(
self,
order_key,
message.clone(),
status,
kind,
completion_message,
);
if !had_tracker {
if let Some(msg) = message {
self.push_background_tail(msg);
}
}
if matches!(status, AutoDriveStatus::Stopped) {
self.auto_state.last_completion_explanation = None;
}
auto_drive_cards::clear(self);
}
@@ -4773,13 +4791,14 @@ impl ChatWidget<'_> {
/// Check if there are any animations and trigger redraw if needed
pub fn check_for_initial_animations(&mut self) {
if self.history_cells.iter().any(|cell| cell.is_animating()) {
if Self::auto_reduced_motion_preference() {
return;
}
tracing::info!("Initial animation detected, scheduling frame");
// Schedule initial frame for animations to ensure they start properly.
// Use ScheduleFrameIn to avoid debounce issues with immediate RequestRedraw.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
50,
)));
.send(AppEvent::ScheduleFrameIn(HISTORY_ANIMATION_FRAME_INTERVAL));
}
}
@@ -11528,7 +11547,17 @@ impl ChatWidget<'_> {
auto_drive_card.set_status(history_cell::AutoDriveStatus::Paused);
self.history_push(auto_drive_card);
self.push_background_tail("demo: finished populating sample history.");
let goal = "Stabilize nightly CI pipeline".to_string();
self.auto_state.last_run_summary = Some(AutoRunSummary {
duration: Duration::from_secs(95),
turns_completed: 4,
message: Some("Auto Drive completed demo run.".to_string()),
goal: Some(goal),
});
let celebration_message = "Diagnostics report: all demo checks passed.".to_string();
self.auto_state.last_completion_explanation = Some(celebration_message.clone());
self.schedule_auto_drive_card_celebration(Duration::from_secs(2), Some(celebration_message));
self.request_redraw();
}
@@ -13920,6 +13949,10 @@ Have we met every part of this goal and is there no further work to do?"#
} else {
self.auto_card_finalize(None, AutoDriveStatus::Stopped, AutoDriveActionKind::Info);
}
self.schedule_auto_drive_card_celebration(
Duration::from_secs(0),
self.auto_state.last_completion_explanation.clone(),
);
self.auto_turn_review_state = None;
if ENABLE_WARP_STRIPES {
self.header_wave.set_enabled(false, Instant::now());
@@ -14218,6 +14251,22 @@ Have we met every part of this goal and is there no further work to do?"#
return;
}
let existing_base = self
.auto_turn_review_state
.as_ref()
.and_then(|state| state.base_commit.as_ref());
if existing_base.is_some() {
return;
}
#[cfg(test)]
{
if CAPTURE_AUTO_TURN_COMMIT_STUB.lock().unwrap().is_some() {
return;
}
}
match self.capture_auto_turn_commit("auto turn base snapshot", None) {
Ok(commit) => {
self.auto_turn_review_state = Some(AutoTurnReviewState {
@@ -14884,10 +14933,7 @@ Have we met every part of this goal and is there no further work to do?"#
.filter(|value| !value.trim().is_empty());
let bootstrap_pending = self.auto_pending_goal_request;
let continue_mode = self.auto_state.continue_mode;
let continue_cta_active = self.auto_state.awaiting_coordinator_submit()
&& !self.auto_state.is_paused_manual()
&& continue_mode != AutoContinueMode::Manual;
let continue_cta_active = self.auto_should_show_continue_cta();
let countdown_limit = self.auto_state.countdown_seconds();
let countdown_active = self.auto_state.countdown_active();
@@ -14999,6 +15045,14 @@ Have we met every part of this goal and is there no further work to do?"#
}
}
fn auto_should_show_continue_cta(&self) -> bool {
self.auto_state.is_active()
&& self.auto_state.awaiting_coordinator_submit()
&& !self.auto_state.is_paused_manual()
&& self.config.auto_drive.coordinator_routing
&& self.auto_state.continue_mode != AutoContinueMode::Manual
}
fn auto_format_status_headline(&self, text: &str) -> String {
let trimmed = text.trim_end();
if trimmed.is_empty() {
@@ -15253,14 +15307,29 @@ Have we met every part of this goal and is there no further work to do?"#
progress_current: &Option<String>,
progress_past: &Option<String>,
) -> String {
let mut lines = Vec::new();
if let Some(current) = progress_current.as_ref() {
lines.push(current.clone());
let mut parts: Vec<String> = Vec::new();
if let Some(current) = progress_current
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
parts.push(current.to_string());
}
if let Some(past) = progress_past.as_ref() {
lines.push(past.clone());
if let Some(past) = progress_past
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
if !parts.iter().any(|existing| existing.eq_ignore_ascii_case(past)) {
parts.push(past.to_string());
}
}
match parts.len() {
0 => String::new(),
1 => parts.into_iter().next().unwrap(),
_ => parts.join(" · "),
}
lines.join("\n")
}
fn auto_append_progress_lines(
@@ -18079,6 +18148,94 @@ Have we met every part of this goal and is there no further work to do?"#
self.request_redraw();
}
fn schedule_auto_drive_card_celebration(
&self,
delay: Duration,
message: Option<String>,
) {
let event = AppEvent::StartAutoDriveCelebration { message };
self.spawn_app_event_after(delay, event);
}
pub(crate) fn start_auto_drive_card_celebration(&mut self, message: Option<String>) {
let mut started = auto_drive_cards::start_celebration(self, message.clone());
if !started {
if let Some(card) = self.latest_auto_drive_card_mut() {
card.start_celebration(message.clone());
started = true;
}
}
if !started {
return;
}
self.spawn_app_event_after(
AUTO_COMPLETION_CELEBRATION_DURATION,
AppEvent::StopAutoDriveCelebration,
);
if let Some(msg) = message.clone() {
if !auto_drive_cards::update_completion_message(self, Some(msg.clone())) {
if let Some(card) = self.latest_auto_drive_card_mut() {
card.set_completion_message(Some(msg));
}
}
}
self.mark_history_dirty();
self.request_redraw();
}
pub(crate) fn stop_auto_drive_card_celebration(&mut self) {
let mut stopped = auto_drive_cards::stop_celebration(self);
if !stopped {
if let Some(card) = self.latest_auto_drive_card_mut() {
card.stop_celebration();
stopped = true;
}
}
if stopped {
self.mark_history_dirty();
self.request_redraw();
}
}
fn spawn_app_event_after(&self, delay: Duration, event: AppEvent) {
if delay.is_zero() {
self.app_event_tx.send(event);
return;
}
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let tx = self.app_event_tx.clone();
handle.spawn(async move {
tokio::time::sleep(delay).await;
tx.send(event);
});
} else {
#[cfg(test)]
{
let tx = self.app_event_tx.clone();
std::thread::spawn(move || {
tx.send(event);
});
}
#[cfg(not(test))]
{
let _ = event;
}
}
}
fn latest_auto_drive_card_mut(
&mut self,
) -> Option<&mut history_cell::AutoDriveCardCell> {
self.history_cells
.iter_mut()
.rev()
.find_map(|cell| cell.as_any_mut().downcast_mut::<history_cell::AutoDriveCardCell>())
}
pub(crate) fn auto_manual_entry_active(&self) -> bool {
self.auto_state.should_show_goal_entry()
|| (self.auto_state.is_active() && self.auto_state.awaiting_coordinator_submit())
@@ -18106,10 +18263,7 @@ Have we met every part of this goal and is there no further work to do?"#
}
if self.auto_state.is_active() {
let continue_mode = self.auto_state.continue_mode;
let awaiting_continue_cta = self.auto_state.awaiting_coordinator_submit()
&& !self.auto_state.is_paused_manual()
&& continue_mode != AutoContinueMode::Manual;
let awaiting_continue_cta = self.auto_should_show_continue_cta();
if awaiting_continue_cta {
return EscRoute::new(EscIntent::AutoStopDuringApproval, true, false);
@@ -18782,6 +18936,13 @@ Have we met every part of this goal and is there no further work to do?"#
{
Ok(check) => {
if check.complete {
let explanation = check.explanation.trim();
if explanation.is_empty() {
self.auto_state.last_completion_explanation = None;
} else {
self.auto_state.last_completion_explanation =
Some(explanation.to_string());
}
let pending = self.auto_state.pending_stop_message.take();
if let Some(idx) = self.history_cells.iter().rposition(|c| {
c.as_any()
@@ -18806,6 +18967,7 @@ Have we met every part of this goal and is there no further work to do?"#
self.auto_stop(pending);
return;
} else {
self.auto_state.last_completion_explanation = None;
let goal = self
.auto_state
.goal
@@ -18832,6 +18994,7 @@ Have we met every part of this goal and is there no further work to do?"#
"failed to parse diagnostics completion check: {}",
err
);
self.auto_state.last_completion_explanation = None;
let pending = self.auto_state.pending_stop_message.take();
self.auto_stop(pending);
}
@@ -28926,12 +29089,10 @@ impl WidgetRef for &ChatWidget<'_> {
.map(|cell| cell.is_animating())
.unwrap_or(false)
});
if has_visible_animation {
if has_visible_animation && !ChatWidget::auto_reduced_motion_preference() {
tracing::debug!("Visible animation detected, scheduling next frame");
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
50,
)));
.send(AppEvent::ScheduleFrameIn(HISTORY_ANIMATION_FRAME_INTERVAL));
}
let render_loop_start = if self.perf_state.enabled {
@@ -99,6 +99,7 @@ pub(super) fn finalize(
message: Option<String>,
status: AutoDriveStatus,
action_kind: AutoDriveActionKind,
completion_message: Option<String>,
) {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.request_ordinal = order_key.req;
@@ -106,6 +107,7 @@ pub(super) fn finalize(
if let Some(msg) = message {
tracker.cell.push_action(msg, action_kind);
}
tracker.cell.set_completion_message(completion_message);
tracker.cell.set_status(status);
tracker.replace(chat);
tracker.active = false;
@@ -113,6 +115,42 @@ pub(super) fn finalize(
}
}
pub(super) fn start_celebration(
chat: &mut ChatWidget<'_>,
message: Option<String>,
) -> bool {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.cell.start_celebration(message);
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
return true;
}
false
}
pub(super) fn stop_celebration(chat: &mut ChatWidget<'_>) -> bool {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.cell.stop_celebration();
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
return true;
}
false
}
pub(super) fn update_completion_message(
chat: &mut ChatWidget<'_>,
message: Option<String>,
) -> bool {
if let Some(mut tracker) = chat.tools_state.auto_drive_tracker.take() {
tracker.cell.set_completion_message(message);
tracker.replace(chat);
chat.tools_state.auto_drive_tracker = Some(tracker);
return true;
}
false
}
pub(super) fn clear(chat: &mut ChatWidget<'_>) {
chat.tools_state.auto_drive_tracker = None;
}
+565 -8
View File
@@ -13,17 +13,19 @@ use super::card_style::{
};
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use crate::card_theme;
use crate::glitch_animation::{gradient_multi, mix_rgb};
use crate::gradient_background::{GradientBackground, RevealRender};
use crate::colors;
use code_common::elapsed::format_duration_digital;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Text};
use ratatui::widgets::{Paragraph, Widget, Wrap};
use unicode_width::UnicodeWidthStr;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use std::f32;
use std::time::{Duration, Instant};
use std::env;
const BORDER_TOP: &str = "╭─";
const BORDER_BODY: &str = "│";
@@ -32,6 +34,14 @@ const HINT_TEXT: &str = " [Ctrl+S] Settings · [Esc] Stop";
const ACTION_TIME_INDENT: usize = 1;
const ACTION_TIME_SEPARATOR_WIDTH: usize = 2;
const ACTION_TIME_COLUMN_MIN_WIDTH: usize = 6;
const CELEBRATION_ASCII: [&str; 4] = [
" ▗▄▄▖ ▗▄▖ ▗▖ ▗▖▗▄▄▖ ▗▖ ▗▄▄▄▖▗▄▄▄▖▗▄▄▄▖",
"▐▌ ▐▌ ▐▌▐▛▚▞▜▌▐▌ ▐▌▐▌ ▐▌ █ ▐▌ ",
"▐▌ ▐▌ ▐▌▐▌ ▐▌▐▛▀▘ ▐▌ ▐▛▀▀▘ █ ▐▛▀▀▘",
"▝▚▄▄▖▝▚▄▞▘▐▌ ▐▌▐▌ ▐▙▄▄▖▐▙▄▄▖ █ ▐▙▄▄▖",
];
const CELEBRATION_SPARKLE_CHOICES: &[char] = &['*', '+', 'x', '·', '•', '✶'];
const CELEBRATION_FRAME_INTERVAL: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AutoDriveStatus {
@@ -89,6 +99,8 @@ pub(crate) struct AutoDriveCardCell {
signature: Option<String>,
reveal_started_at: Option<Instant>,
first_action_at: Option<Instant>,
completion_message: Option<String>,
celebration_started_at: Option<Instant>,
}
impl AutoDriveCardCell {
@@ -105,6 +117,8 @@ impl AutoDriveCardCell {
signature: None,
reveal_started_at,
first_action_at: None,
completion_message: None,
celebration_started_at: None,
};
cell
}
@@ -130,6 +144,22 @@ impl AutoDriveCardCell {
.push(AutoDriveAction::new(text.into(), kind, elapsed));
}
pub(crate) fn set_completion_message(&mut self, message: Option<String>) {
self.completion_message = message.and_then(Self::normalize_text);
}
pub(crate) fn start_celebration(&mut self, message: Option<String>) {
self.celebration_started_at = Some(Instant::now());
if let Some(msg) = message.and_then(Self::normalize_text) {
self.completion_message = Some(msg);
}
self.status = AutoDriveStatus::Stopped;
}
pub(crate) fn stop_celebration(&mut self) {
self.celebration_started_at = None;
}
fn reveal_progress(&self) -> Option<(f32, card_theme::CardThemeDefinition)> {
let theme = active_auto_drive_theme();
let reveal = theme.theme.reveal?;
@@ -163,8 +193,13 @@ impl AutoDriveCardCell {
let mut rows: Vec<CardRow> = Vec::new();
if self.celebration_started_at.is_some() {
rows.extend(self.build_celebration_rows(body_width, style));
return rows;
}
rows.push(self.title_row(body_width, style));
rows.push(self.blank_row(body_width, style));
rows.push(self.celebration_blank_row(body_width, style));
if let Some(goal) = &self.goal {
rows.push(self.goal_row(goal.as_str(), body_width, style));
@@ -180,6 +215,46 @@ impl AutoDriveCardCell {
}
rows.push(self.blank_row(body_width, style));
if let Some(completion) = self.completion_message.as_ref() {
rows.push(self.complete_heading_row(body_width, style));
let mut completion_rows = self.complete_message_rows(completion, body_width, style);
if completion_rows.is_empty() {
rows.push(self.blank_row(body_width, style));
} else {
rows.append(&mut completion_rows);
}
rows.push(self.blank_row(body_width, style));
}
rows.push(self.bottom_border_row(body_width, style));
rows
}
fn build_celebration_rows(&self, body_width: usize, style: &CardStyle) -> Vec<CardRow> {
let mut rows: Vec<CardRow> = Vec::new();
rows.push(self.title_row(body_width, style));
rows.push(self.blank_row(body_width, style));
let lines = self.celebration_body_lines(body_width);
for (line_index, line) in lines.into_iter().enumerate() {
let segments = if line_index > 0 && line_index - 1 < CELEBRATION_ASCII.len() {
self.celebration_ascii_segments(line)
} else {
vec![CardSegment::new(line, Self::celebration_background_style())]
};
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
}
rows.push(self.celebration_blank_row(body_width, style));
rows.push(self.bottom_border_row(body_width, style));
rows
@@ -188,7 +263,11 @@ impl AutoDriveCardCell {
fn title_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let mut segments: Vec<CardSegment> = Vec::new();
let title_text = " Auto Drive";
let status_text = format!(" · {}", self.status.label());
let status_text = if self.celebration_started_at.is_some() {
" · Complete".to_string()
} else {
format!(" · {}", self.status.label())
};
let combined = format!("{title_text}{status_text}");
if UnicodeWidthStr::width(combined.as_str()) <= body_width {
@@ -227,6 +306,16 @@ impl AutoDriveCardCell {
)
}
fn celebration_blank_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let filler = " ".repeat(body_width);
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
vec![CardSegment::new(filler, Self::celebration_background_style())],
None,
)
}
fn goal_row(&self, goal: &str, body_width: usize, style: &CardStyle) -> CardRow {
let cleaned = goal.trim();
let value = format!(" {}", cleaned);
@@ -309,6 +398,226 @@ impl AutoDriveCardCell {
)
}
fn complete_heading_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
if body_width == 0 {
return CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
Vec::new(),
None,
);
}
let mut segments = Vec::new();
if ACTION_TIME_INDENT > 0 {
segments.push(CardSegment::new(
" ".repeat(ACTION_TIME_INDENT),
secondary_text_style(style),
));
}
let available = body_width.saturating_sub(ACTION_TIME_INDENT);
if available > 0 {
let label = truncate_with_ellipsis("Complete", available);
let mut heading = CardSegment::new(label, primary_text_style(style));
heading.inherit_background = true;
segments.push(heading);
}
CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
)
}
fn complete_message_rows(&self, message: &str, body_width: usize, style: &CardStyle) -> Vec<CardRow> {
if body_width == 0 {
return Vec::new();
}
let indent_text = if ACTION_TIME_INDENT > 0 {
" ".repeat(ACTION_TIME_INDENT)
} else {
String::new()
};
let indent_style = secondary_text_style(style);
let content_style = primary_text_style(style);
let available = body_width.saturating_sub(ACTION_TIME_INDENT);
if available == 0 {
return Vec::new();
}
let mut rows = Vec::new();
for (idx, paragraph) in message.lines().enumerate() {
let trimmed = paragraph.trim();
if trimmed.is_empty() {
if idx > 0 {
rows.push(self.blank_row(body_width, style));
}
continue;
}
for segment in Self::wrap_segments(trimmed, available) {
let mut segments = Vec::new();
if ACTION_TIME_INDENT > 0 {
segments.push(CardSegment::new(indent_text.clone(), indent_style));
}
let mut body = CardSegment::new(segment, content_style);
body.inherit_background = true;
segments.push(body);
rows.push(CardRow::new(
BORDER_BODY.to_string(),
Self::accent_style(style),
segments,
None,
));
}
}
rows
}
fn celebration_body_lines(&self, body_width: usize) -> Vec<String> {
self.celebration_body_lines_at(body_width, Instant::now())
}
fn celebration_body_lines_at(&self, body_width: usize, now: Instant) -> Vec<String> {
let reduced_motion = Self::celebration_reduced_motion();
self.celebration_body_lines_internal(body_width, now, reduced_motion)
}
#[cfg(test)]
fn celebration_body_lines_at_with_reduced_motion(
&self,
body_width: usize,
now: Instant,
reduced_motion: bool,
) -> Vec<String> {
self.celebration_body_lines_internal(body_width, now, reduced_motion)
}
fn celebration_frame_index_at(started_at: Instant, now: Instant, reduced_motion: bool) -> usize {
if reduced_motion {
return 0;
}
let interval_ms = CELEBRATION_FRAME_INTERVAL.as_millis().max(1);
let elapsed = now.saturating_duration_since(started_at);
(elapsed.as_millis() / interval_ms) as usize
}
fn celebration_body_lines_internal(
&self,
body_width: usize,
now: Instant,
reduced_motion: bool,
) -> Vec<String> {
if body_width == 0 {
return Vec::new();
}
let frame = self
.celebration_started_at
.map(|started| Self::celebration_frame_index_at(started, now, reduced_motion))
.unwrap_or(0);
let mut lines: Vec<String> = Vec::new();
lines.push(Self::pad_to_width("", body_width));
let ascii_block_width = CELEBRATION_ASCII
.iter()
.map(|line| Self::display_width(line))
.max()
.unwrap_or(0);
let (ascii_left_pad, ascii_right_pad) = if ascii_block_width <= body_width {
let left = (body_width - ascii_block_width) / 2;
let right = body_width - left - ascii_block_width;
(left, right)
} else {
(0, 0)
};
for (ascii_index, ascii) in CELEBRATION_ASCII.iter().enumerate() {
let mut line = if ascii_block_width <= body_width {
Self::pad_line_for_block(ascii, ascii_block_width, ascii_left_pad, ascii_right_pad)
} else {
Self::pad_to_width(ascii, body_width)
};
if !reduced_motion {
let protected = Self::occupied_range(&line);
line = Self::sprinkle_sparkles(line, frame as u64, ascii_index as u64, protected);
}
lines.push(line);
}
lines.push(Self::pad_to_width("", body_width));
lines
}
fn celebration_reduced_motion() -> bool {
match env::var("CODE_TUI_REDUCED_MOTION") {
Ok(value) => {
let normalized = value.trim().to_ascii_lowercase();
!matches!(normalized.as_str(), "" | "0" | "false" | "off" | "no")
}
Err(_) => false,
}
}
fn sprinkle_sparkles(
line: String,
frame_seed: u64,
row_seed: u64,
protected_range: Option<(usize, usize)>,
) -> String {
let mut chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return line;
}
let protected = protected_range.unwrap_or((0, 0));
let (protected_start, protected_end) = protected;
let mut state = frame_seed
.wrapping_mul(0x9E3779B97F4A7C15)
.wrapping_add(row_seed.wrapping_mul(0xBF58476D1CE4E5B9))
^ 0x94D049BB133111EB;
let sparkle_count = 3 + (Self::prng_step(&mut state) % 4) as usize;
for _ in 0..sparkle_count {
let position = (Self::prng_step(&mut state) as usize) % chars.len();
let in_protected = position >= protected_start && position < protected_end;
if !in_protected && chars[position] == ' ' {
let sparkle_idx = (Self::prng_step(&mut state) as usize)
% CELEBRATION_SPARKLE_CHOICES.len();
chars[position] = CELEBRATION_SPARKLE_CHOICES[sparkle_idx];
}
}
chars.into_iter().collect()
}
fn occupied_range(line: &str) -> Option<(usize, usize)> {
let mut start = None;
let mut end = 0usize;
for (idx, ch) in line.chars().enumerate() {
if ch != ' ' {
start = start.or(Some(idx));
end = idx + 1;
}
}
start.map(|s| (s, end))
}
fn prng_step(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1);
*state
}
fn action_rows(&self, body_width: usize, style: &CardStyle) -> Vec<CardRow> {
if body_width == 0 {
return Vec::new();
@@ -400,6 +709,191 @@ impl AutoDriveCardCell {
rows
}
fn wrap_segments(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut rows: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in text.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
if current.is_empty() {
if word_width > width {
rows.extend(Self::chunk_long_word(word, width));
current.clear();
current_width = 0;
} else {
current.push_str(word);
current_width = word_width;
}
continue;
}
let needs_space = 1;
if current_width + needs_space + word_width <= width {
current.push(' ');
current.push_str(word);
current_width += needs_space + word_width;
} else {
rows.push(Self::pad_to_width(&current, width));
current.clear();
current_width = 0;
if word_width > width {
rows.extend(Self::chunk_long_word(word, width));
} else {
current.push_str(word);
current_width = word_width;
}
}
}
if !current.is_empty() {
rows.push(Self::pad_to_width(&current, width));
}
if rows.is_empty() {
rows.push(Self::pad_to_width("", width));
}
rows
}
fn chunk_long_word(word: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut rows: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for ch in word.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > width && !current.is_empty() {
rows.push(Self::pad_to_width(&current, width));
current.clear();
current_width = 0;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
rows.push(Self::pad_to_width(&current, width));
}
rows
}
fn pad_to_width(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let mut output = String::new();
let mut accumulated = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if accumulated + ch_width > width {
break;
}
output.push(ch);
accumulated += ch_width;
}
if accumulated < width {
output.push_str(&" ".repeat(width - accumulated));
}
output
}
fn pad_line_for_block(
line: &str,
block_width: usize,
left_pad: usize,
right_pad: usize,
) -> String {
let mut output = String::with_capacity(left_pad + block_width + right_pad);
output.push_str(&" ".repeat(left_pad));
output.push_str(line);
let line_width = Self::display_width(line);
if line_width < block_width {
output.push_str(&" ".repeat(block_width - line_width));
}
output.push_str(&" ".repeat(right_pad));
output
}
fn display_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
fn celebration_ascii_segments(&self, line: String) -> Vec<CardSegment> {
let chars: Vec<char> = line.chars().collect();
let total = chars.len();
let left_pad = chars.iter().take_while(|c| **c == ' ').count();
let right_pad = chars.iter().rev().take_while(|c| **c == ' ').count();
let ascii_span_end = total.saturating_sub(right_pad);
let ascii_slice = &chars[left_pad..ascii_span_end];
let ascii_len = ascii_slice.len().max(1);
let mut segments = Vec::new();
let mut buffer = String::new();
let mut active_style: Option<(Color, bool)> = None;
let flush = |segments: &mut Vec<CardSegment>, buffer: &mut String, style: Option<(Color, bool)>| {
if let Some((color, bold)) = style {
if !buffer.is_empty() {
let mut segment_style = Style::default().fg(color);
if bold {
segment_style = segment_style.add_modifier(Modifier::BOLD);
}
segments.push(CardSegment::new(std::mem::take(buffer), segment_style));
}
}
};
for idx in 0..total {
let ch = chars[idx];
let style_for_char = if idx < left_pad || idx >= ascii_span_end {
(Color::Rgb(255, 255, 255), false)
} else {
let ascii_pos = idx - left_pad;
let ascii_ch = ascii_slice[ascii_pos];
if ascii_ch == ' ' {
(Color::Rgb(255, 255, 255), false)
} else {
let denom = (ascii_len - 1).max(1) as f32;
let ratio = (ascii_pos as f32) / denom;
let base = gradient_multi(ratio);
let neon = mix_rgb(base, Color::Rgb(255, 255, 255), 0.18);
(neon, true)
}
};
if active_style.map(|s| s == style_for_char).unwrap_or(false) {
buffer.push(ch);
} else {
flush(&mut segments, &mut buffer, active_style);
buffer.push(ch);
active_style = Some(style_for_char);
}
}
flush(&mut segments, &mut buffer, active_style);
segments
}
fn celebration_background_style() -> Style {
Style::default().fg(Color::Rgb(255, 255, 255))
}
fn bottom_border_row(&self, body_width: usize, style: &CardStyle) -> CardRow {
let text = truncate_with_ellipsis(HINT_TEXT, body_width);
let mut segment = CardSegment::new(text, hint_text_style(style));
@@ -535,14 +1029,24 @@ impl HistoryCell for AutoDriveCardCell {
for action in &self.actions {
lines.push(Line::from(format!("- {}", action.text)));
}
if let Some(completion) = &self.completion_message {
lines.push(Line::from("complete:"));
for line in completion.lines() {
if line.trim().is_empty() {
continue;
}
lines.push(Line::from(format!(" {line}")));
}
}
lines
}
fn is_animating(&self) -> bool {
self
.reveal_progress()
.map(|(progress, _)| progress < 0.999)
.unwrap_or(false)
self.celebration_started_at.is_some()
|| self
.reveal_progress()
.map(|(progress, _)| progress < 0.999)
.unwrap_or(false)
}
fn desired_height(&self, width: u16) -> u16 {
@@ -557,3 +1061,56 @@ impl HistoryCell for AutoDriveCardCell {
self.render_rows(area, buf, skip_rows);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
const TEST_WIDTH: usize = 60;
#[test]
fn celebration_lines_have_fixed_width() {
let mut cell = AutoDriveCardCell::new(Some("Goal".to_string()));
let start = Instant::now();
cell.celebration_started_at = Some(start);
let lines = cell.celebration_body_lines_at_with_reduced_motion(
TEST_WIDTH,
start + Duration::from_millis(120),
false,
);
assert!(!lines.is_empty());
assert!(lines
.iter()
.all(|line| UnicodeWidthStr::width(line.as_str()) == TEST_WIDTH));
}
#[test]
fn celebration_frames_differ_over_time() {
let mut cell = AutoDriveCardCell::new(Some("Goal".to_string()));
let start = Instant::now();
cell.celebration_started_at = Some(start);
let first = cell.celebration_body_lines_at_with_reduced_motion(
TEST_WIDTH,
start + Duration::from_millis(120),
false,
);
let second = cell.celebration_body_lines_at_with_reduced_motion(
TEST_WIDTH,
start + Duration::from_millis(360),
false,
);
assert!(first
.iter()
.zip(second.iter())
.any(|(a, b)| a != b), "expected frames to differ");
for line in first.iter().chain(second.iter()) {
assert_eq!(UnicodeWidthStr::width(line.as_str()), TEST_WIDTH);
}
}
}