use super::MissionControlState;
use crate::output::{ActivityEvent, ActivityId, ActivityStatus};
use std::collections::VecDeque;
use std::time::{Duration, Instant};
pub(crate) const ACTIVITY_FRAME_INTERVAL: Duration = Duration::from_millis(83);
const COMPLETION_HIGHLIGHT_DURATION: Duration = Duration::from_millis(650);
const DOT_ORBIT: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
#[derive(Debug, Default)]
pub(crate) struct ActivityMotion {
pub(crate) phase: usize,
pub(crate) run_namespace: Option<String>,
last_frame: Option<Instant>,
completion_until: Option<Instant>,
completed_cards: VecDeque<(ActivityId, Instant)>,
}
impl MissionControlState {
pub(crate) fn observe_live_card_completion(&mut self, event: &ActivityEvent, now: Instant) {
let (id, status) = match event {
ActivityEvent::Finished { id, status, .. } => (id, *status),
ActivityEvent::ToolResultDetail { id, detail } => (id, detail.status),
ActivityEvent::FinalPreview {
id,
status: Some(status),
..
} => (id, *status),
_ => return,
};
self.observe_live_card_status(id, status, now);
}
pub(crate) fn observe_live_tool_result_completion(
&mut self,
event: &crate::output::OutputEvent,
now: Instant,
) {
if let crate::output::OutputEvent::ToolResult { call, result, .. } = event
&& result.success
&& !call.id.trim().is_empty()
{
self.observe_live_card_status(
&ActivityId::new(call.id.clone()),
ActivityStatus::Success,
now,
);
}
}
fn observe_live_card_status(&mut self, id: &ActivityId, status: ActivityStatus, now: Instant) {
if !self.activity_motion_running()
|| status != ActivityStatus::Success
|| !self.nodes.get(id).is_some_and(|node| {
matches!(
node.status,
ActivityStatus::Running | ActivityStatus::Queued | ActivityStatus::Writing
)
})
{
return;
}
let completed = &mut self.activity_motion.completed_cards;
if completed.iter().any(|(existing, _)| existing == id) {
return;
}
const MAX_COMPLETION_ACCENTS: usize = 128;
if completed.len() == MAX_COMPLETION_ACCENTS {
completed.pop_front();
}
completed.push_back((id.clone(), now + COMPLETION_HIGHLIGHT_DURATION));
}
pub(crate) fn card_completion_highlight_visible(&self, id: &ActivityId) -> bool {
!self.reduced_motion
&& self.top_modal().is_none()
&& self
.nodes
.get(id)
.is_some_and(|node| node.status == ActivityStatus::Success)
&& self
.activity_motion
.completed_cards
.iter()
.any(|(completed, _)| completed == id)
}
pub(crate) fn card_spinner_visible(&self, id: &ActivityId) -> bool {
self.activity_motion_running()
&& self
.activity_motion
.run_namespace
.as_ref()
.is_some_and(|namespace| {
!namespace.is_empty() && id.as_str().starts_with(namespace)
})
}
pub(crate) fn activity_scanner(&self) -> &'static str {
const SCANNER: [&str; 10] = [
"[>.....]", "[.>....]", "[..>...]", "[...>..]", "[....>.]", "[.....<]", "[....<.]",
"[...<..]", "[..<...]", "[.<....]",
];
SCANNER[self.activity_motion.phase % SCANNER.len()]
}
pub(crate) fn activity_motion_running(&self) -> bool {
!self.reduced_motion && self.top_modal().is_none() && self.running_prompt_cancelable()
}
fn summary_motion_running(&self) -> bool {
self.summary.running && !self.reduced_motion && self.top_modal().is_none()
}
pub(crate) fn summary_spinner(&self) -> &'static str {
if self.summary_motion_running() {
self.activity_spinner()
} else {
DOT_ORBIT[0]
}
}
pub(crate) fn completion_highlight_visible(&self) -> bool {
!self.reduced_motion
&& self.top_modal().is_none()
&& self.activity_motion.completion_until.is_some()
}
pub(crate) fn highlight_live_completion(&mut self, now: Instant) {
if self.running_prompt_cancelable() && !self.reduced_motion && self.top_modal().is_none() {
self.activity_motion.completion_until = Some(now + COMPLETION_HIGHLIGHT_DURATION);
}
}
pub(crate) fn activity_motion_timeout(&self, now: Instant) -> Option<Duration> {
let frame = (self.activity_motion_running() || self.summary_motion_running()).then(|| {
self.activity_motion
.last_frame
.map_or(now + ACTIVITY_FRAME_INTERVAL, |last| {
last + ACTIVITY_FRAME_INTERVAL
})
});
frame
.into_iter()
.chain(self.activity_motion.completion_until)
.chain(
self.activity_motion
.completed_cards
.iter()
.map(|(_, until)| *until),
)
.min()
.map(|due| due.saturating_duration_since(now))
}
pub(crate) fn tick_activity_motion(&mut self, now: Instant) -> bool {
let mut changed = false;
let previous_count = self.activity_motion.completed_cards.len();
self.activity_motion
.completed_cards
.retain(|(_, until)| now < *until && !self.reduced_motion);
changed |= previous_count != self.activity_motion.completed_cards.len();
if self
.activity_motion
.completion_until
.is_some_and(|until| now >= until || self.reduced_motion)
{
self.activity_motion.completion_until = None;
changed = true; }
if !self.activity_motion_running() && !self.summary_motion_running() {
self.activity_motion.last_frame = None;
return changed;
}
match self.activity_motion.last_frame {
Some(last) if now.saturating_duration_since(last) >= ACTIVITY_FRAME_INTERVAL => {
self.activity_motion.phase = self.activity_motion.phase.wrapping_add(1);
self.activity_motion.last_frame = Some(now);
true
}
None => {
self.activity_motion.last_frame = Some(now);
changed
}
_ => changed,
}
}
pub(crate) fn activity_spinner(&self) -> &'static str {
DOT_ORBIT[self.activity_motion.phase % DOT_ORBIT.len()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_spinner_runs_independently_and_settles_when_idle_or_motion_is_disabled() {
let now = Instant::now();
let mut state = MissionControlState::default();
let idle = state.summary_spinner();
state.summary.running = true;
assert_eq!(
state.activity_motion_timeout(now),
Some(ACTIVITY_FRAME_INTERVAL)
);
state.tick_activity_motion(now);
assert!(state.tick_activity_motion(now + ACTIVITY_FRAME_INTERVAL));
assert_ne!(state.summary_spinner(), idle);
assert!(!state.activity_motion_running());
state.reduced_motion = true;
assert_eq!(state.summary_spinner(), idle);
assert_eq!(state.activity_motion_timeout(now), None);
state.reduced_motion = false;
state.open_skills_modal(Vec::new(), 1);
assert_eq!(state.activity_motion_timeout(now), None);
state.close_skills_modal();
assert!(state.activity_motion_timeout(now).is_some());
state.summary.running = false;
assert_eq!(state.summary_spinner(), idle);
assert_eq!(state.activity_motion_timeout(now), None);
state.start_running_prompt("work".into());
state.tick_activity_motion(now);
assert!(state.tick_activity_motion(now + ACTIVITY_FRAME_INTERVAL));
assert_eq!(state.summary_spinner(), idle);
state.summary.running = true;
state.clear_running_prompt();
assert!(state.activity_motion_timeout(now).is_some());
}
#[test]
fn activity_clock_only_runs_for_live_motion_and_does_not_catch_up() {
let now = Instant::now();
let mut state = MissionControlState::default();
assert_eq!(state.activity_motion_timeout(now), None);
state.start_running_prompt("work".into());
assert!(!state.tick_activity_motion(now));
assert!(!state.tick_activity_motion(now + ACTIVITY_FRAME_INTERVAL / 2));
assert!(state.tick_activity_motion(now + Duration::from_secs(3)));
assert_eq!(state.activity_motion.phase, 1);
state.reduced_motion = true;
assert!(!state.tick_activity_motion(now + Duration::from_secs(4)));
assert_eq!(state.activity_motion_timeout(now), None);
state.reduced_motion = false;
state.request_running_prompt_cancel();
assert_eq!(state.activity_motion_timeout(now), None);
}
#[test]
fn completion_requires_live_success_and_requests_a_final_settled_frame() {
let now = Instant::now();
let mut state = MissionControlState::default();
state.highlight_live_completion(now);
assert!(!state.completion_highlight_visible());
state.start_running_prompt("work".into());
state.highlight_live_completion(now);
state.clear_running_prompt();
assert!(state.completion_highlight_visible());
assert_eq!(
state.activity_motion_timeout(now),
Some(COMPLETION_HIGHLIGHT_DURATION)
);
assert!(state.tick_activity_motion(now + COMPLETION_HIGHLIGHT_DURATION));
assert!(!state.completion_highlight_visible());
assert_eq!(state.activity_motion_timeout(now), None);
state.start_running_prompt("next".into());
state.request_running_prompt_cancel();
state.highlight_live_completion(now);
assert!(!state.completion_highlight_visible());
}
#[test]
fn activity_motion_pauses_for_modals_and_resets_between_turns_and_sessions() {
let now = Instant::now();
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
state.tick_activity_motion(now);
state.open_skills_modal(Vec::new(), 1);
assert!(!state.activity_motion_running());
assert_eq!(state.activity_motion_timeout(now), None);
assert!(!state.tick_activity_motion(now + Duration::from_secs(1)));
state.close_skills_modal();
assert!(!state.tick_activity_motion(now + Duration::from_secs(2)));
assert_eq!(state.activity_motion.phase, 0);
state.highlight_live_completion(now);
state.start_running_prompt("next".into());
assert!(!state.completion_highlight_visible());
state.highlight_live_completion(now);
state.reset_for_new_session();
assert_eq!(state.activity_motion_timeout(now), None);
assert!(!state.completion_highlight_visible());
}
#[test]
fn worker_errors_and_cancellation_never_get_success_accents() {
use crate::tui::worker::{WorkerFinalEvent, apply_worker_final_event};
for event in [
WorkerFinalEvent::Error("failed".into()),
WorkerFinalEvent::RunCanceled {
prompt: "work".into(),
},
] {
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
state.highlight_live_completion(Instant::now());
apply_worker_final_event(&mut state, event);
assert!(!state.completion_highlight_visible());
}
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
apply_worker_final_event(&mut state, WorkerFinalEvent::Done);
assert!(state.completion_highlight_visible());
}
}