magi-code 0.80.1

Repository-aware CLI coding agent for terminal work
Documentation
use crossbeam_channel::{Receiver, Sender, bounded};

use super::state::MissionControlState;
use crate::config::{McPaths, release_notes};

const BUNDLED_NOTES: &str = include_str!(concat!(env!("OUT_DIR"), "/welcome-release-notes.txt"));

#[derive(Default)]
pub(super) struct WelcomeReleaseNotes {
    loaded: Option<Receiver<Option<&'static str>>>,
    displayed: Option<Sender<()>>,
    worker: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}

impl WelcomeReleaseNotes {
    #[cfg(test)]
    pub(in crate::tui) fn with_test_worker(
        worker: std::thread::JoinHandle<anyhow::Result<()>>,
    ) -> Self {
        Self {
            worker: Some(worker),
            ..Self::default()
        }
    }

    pub(super) fn start(paths: McPaths, eligible: bool) -> Self {
        Self::start_for_release(paths, eligible, env!("CARGO_PKG_VERSION"), BUNDLED_NOTES)
    }

    pub(in crate::tui) fn start_for_release(
        paths: McPaths,
        eligible: bool,
        version: &'static str,
        notes: &'static str,
    ) -> Self {
        if !eligible || notes.trim().is_empty() {
            return Self::default();
        }
        let (loaded_tx, loaded) = bounded(1);
        let (displayed, displayed_rx) = bounded(1);
        let worker = std::thread::Builder::new()
            .name("welcome-release-notes".into())
            .spawn(move || {
                // Unreadable settings never justify a destructive repair or a seen write.
                let unseen = release_notes::has_seen(&paths, version).is_ok_and(|seen| !seen);
                if loaded_tx.send(unseen.then_some(notes)).is_ok()
                    && unseen
                    && displayed_rx.recv().is_ok()
                {
                    release_notes::mark_seen(&paths, version)?;
                }
                Ok(())
            });
        let Ok(worker) = worker else {
            return Self::default();
        };
        Self {
            loaded: Some(loaded),
            displayed: Some(displayed),
            worker: Some(worker),
        }
    }

    pub(super) fn poll(&mut self, state: &mut MissionControlState) -> bool {
        if state.release_notes_displayed.get()
            && let Some(sender) = self.displayed.take()
        {
            let _ = sender.try_send(());
        }
        let Some(receiver) = &self.loaded else {
            return false;
        };
        match receiver.try_recv() {
            Ok(notes) => {
                self.loaded = None;
                if notes.is_some() && state.transcript.is_empty() {
                    state.release_notes = notes;
                    return true;
                }
                self.displayed = None;
            }
            Err(crossbeam_channel::TryRecvError::Disconnected) => {
                self.loaded = None;
                self.displayed = None;
            }
            Err(crossbeam_channel::TryRecvError::Empty) => {}
        }
        false
    }

    // The app calls this after TerminalGuard restores the terminal.
    pub(super) fn cleanup(&mut self) -> Vec<String> {
        self.displayed = None;
        self.loaded = None;
        match self.worker.take().map(|worker| worker.join()) {
            Some(Ok(Err(error))) => vec![format!("release notes persistence failed: {error}")],
            Some(Err(_)) => vec!["release notes worker panicked".into()],
            _ => Vec::new(),
        }
    }
}

pub(super) fn confirm_draw(state: &MissionControlState) {
    if state.release_notes_drawn.get() {
        let end = state.release_notes_page_end.get();
        state
            .release_notes_viewed
            .set(state.release_notes_viewed.get().max(end));
        if end == state.release_notes_total.get() {
            state.release_notes_displayed.set(true);
        }
    }
}

fn accepts_scroll(state: &MissionControlState) -> bool {
    state.release_notes.is_some()
        && state.transcript.is_empty()
        && !state.transcript_only_layout
        && state.right_column_tab != super::state::RightColumnTab::Diff
        && state.conversation_modal.is_none()
        && state.top_modal().is_none()
        && state.autocomplete.is_none()
        && state.toast.is_none()
        && state.release_notes_viewport.get().1 > 0
}

pub(super) fn scroll_key(key: crossterm::event::KeyEvent, state: &MissionControlState) -> bool {
    use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
    if key.kind == KeyEventKind::Release
        || key.modifiers != KeyModifiers::ALT
        || !accepts_scroll(state)
    {
        return false;
    }
    let down = match key.code {
        KeyCode::Down => true,
        KeyCode::Up => false,
        _ => return false,
    };
    scroll_rows(state, down, 1);
    true
}

pub(super) fn mouse_scroll_hit(
    mouse: crossterm::event::MouseEvent,
    state: &MissionControlState,
) -> bool {
    use crossterm::event::MouseEventKind;
    accepts_scroll(state)
        && matches!(
            mouse.kind,
            MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
        )
        && state
            .release_notes_area
            .get()
            .contains((mouse.column, mouse.row).into())
}

pub(super) fn scroll_rows(state: &MissionControlState, down: bool, rows: u16) -> bool {
    if !accepts_scroll(state) {
        return false;
    }
    let offset = state.release_notes_offset.get();
    let height = usize::from(state.release_notes_viewport.get().1);
    let maximum = state.release_notes_total.get().saturating_sub(height);
    let next = if down {
        // Only successful draws confirm rows. Batched input must not jump over unseen rows.
        offset
            .saturating_add(usize::from(rows))
            .min(state.release_notes_viewed.get())
    } else {
        offset.saturating_sub(usize::from(rows))
    }
    .min(maximum);
    state.release_notes_offset.set(next);
    next != offset
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn release_notes_scroll_keys_move_one_row_without_skipping_unconfirmed_rows() {
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let state = MissionControlState {
            release_notes: Some("notes"),
            ..Default::default()
        };
        state.release_notes_viewport.set((40, 3));
        state.release_notes_total.set(10);
        let down = KeyEvent::new(KeyCode::Down, KeyModifiers::ALT);
        assert!(scroll_key(down, &state));
        assert_eq!(state.release_notes_offset.get(), 0);
        state.release_notes_viewed.set(3);
        assert!(scroll_key(down, &state));
        assert_eq!(state.release_notes_offset.get(), 1);
        for _ in 0..20 {
            scroll_key(down, &state);
        }
        assert_eq!(state.release_notes_offset.get(), 3);
        state.release_notes_viewed.set(10);
        scroll_rows(&state, true, u16::MAX);
        assert_eq!(state.release_notes_offset.get(), 7);
        assert!(scroll_key(
            KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
            &state
        ));
        assert_eq!(state.release_notes_offset.get(), 6);
        for code in [KeyCode::PageUp, KeyCode::PageDown, KeyCode::Down] {
            assert!(!scroll_key(KeyEvent::new(code, KeyModifiers::NONE), &state));
        }
        for code in [KeyCode::PageUp, KeyCode::PageDown] {
            assert!(!scroll_key(KeyEvent::new(code, KeyModifiers::ALT), &state));
        }
    }

    fn wait_until(mut ready: impl FnMut() -> bool) {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
        while !ready() {
            assert!(
                std::time::Instant::now() < deadline,
                "release notes worker timed out"
            );
            std::thread::sleep(std::time::Duration::from_millis(1));
        }
    }

    #[test]
    fn release_notes_worker_persists_only_displayed_version_and_skips_next_launch() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        let mut state = MissionControlState::default();
        let mut runtime = WelcomeReleaseNotes::start_for_release(
            paths.clone(),
            true,
            "1.2.3",
            "Approved fixture",
        );
        wait_until(|| runtime.poll(&mut state));
        assert_eq!(state.release_notes, Some("Approved fixture"));
        assert!(!release_notes::has_seen(&paths, "1.2.3").unwrap());
        state.release_notes_displayed.set(true);
        runtime.poll(&mut state);
        assert!(runtime.cleanup().is_empty());
        assert!(release_notes::has_seen(&paths, "1.2.3").unwrap());
        assert!(!release_notes::has_seen(&paths, "1.2.3-beta.1").unwrap());

        let mut next =
            WelcomeReleaseNotes::start_for_release(paths, true, "1.2.3", "Approved fixture");
        let mut state = MissionControlState::default();
        wait_until(|| {
            next.poll(&mut state);
            next.loaded.is_none()
        });
        assert!(state.release_notes.is_none());
    }

    #[test]
    fn release_notes_without_approved_content_do_not_touch_storage() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        let runtime = WelcomeReleaseNotes::start_for_release(paths.clone(), true, "1.2.3", "");
        assert!(runtime.loaded.is_none());
        assert!(!paths.settings_file.exists());
    }
    #[test]
    fn release_notes_acknowledgement_requires_display_and_retains_notes() {
        let (sender, receiver) = bounded(1);
        let mut runtime = WelcomeReleaseNotes {
            loaded: None,
            displayed: Some(sender),
            worker: None,
        };
        let mut state = MissionControlState {
            release_notes: Some("Approved notes"),
            ..Default::default()
        };
        runtime.poll(&mut state);
        assert!(receiver.try_recv().is_err());
        state.release_notes_drawn.set(true);
        runtime.poll(&mut state);
        assert!(
            receiver.try_recv().is_err(),
            "render projection is not a successful terminal draw"
        );
        state.release_notes_displayed.set(true);
        runtime.poll(&mut state);
        assert_eq!(receiver.try_recv(), Ok(()));
        assert_eq!(state.release_notes, Some("Approved notes"));
    }

    #[test]
    fn release_notes_skipped_launch_does_not_start_storage_work() {
        let temp = tempfile::tempdir().unwrap();
        let runtime = WelcomeReleaseNotes::start(McPaths::from_root(temp.path().to_owned()), false);
        assert!(runtime.loaded.is_none());
        assert!(runtime.displayed.is_none());
        assert!(!temp.path().join("settings.json").exists());
    }
}