mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/autodrive): add completion celebration state
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¤t, 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(¤t, 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(¤t, width));
|
||||
current.clear();
|
||||
current_width = 0;
|
||||
}
|
||||
current.push(ch);
|
||||
current_width += ch_width;
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
rows.push(Self::pad_to_width(¤t, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user