magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
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] = ["", "", "", "", "", "", "", ""];

/// Display-only timing. Never participates in transcript cache keys or replay.
#[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 {
    /// Called only at the live event boundary, never during history hydration.
    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; // Paint the settled frame even when all work has stopped.
        }
        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());
    }
}