magi-code 0.96.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"));
const BUNDLED_INDEX: &[(&str, usize, usize)] =
    include!(concat!(env!("OUT_DIR"), "/welcome-release-notes-index.rs"));

fn select_notes(
    notes: &'static str,
    index: &[(&str, usize, usize)],
    unread: &release_notes::UnreadStart,
    current: &str,
) -> Option<&'static str> {
    let mut selected = index
        .iter()
        .filter(|(version, _, _)| unread.contains(version, current));
    let (_, start, first_end) = selected.next()?;
    let end = selected.next_back().map_or(*first_end, |(_, _, end)| *end);
    Some(&notes[*start..end])
}

pub(super) fn eligible_for_launch(
    resumed: bool,
    update_restart: bool,
    prompt: Option<&str>,
) -> bool {
    (!resumed || update_restart) && prompt.is_none()
}

pub(super) fn shows_restart_welcome(state: &MissionControlState) -> bool {
    state.update_restart_welcome && state.release_notes.is_some() && !state.transcript_only_layout
}

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

impl WelcomeReleaseNotes {
    pub(super) fn start(paths: McPaths, eligible: bool) -> Self {
        Self::start_with_index(
            paths,
            eligible,
            env!("CARGO_PKG_VERSION"),
            BUNDLED_NOTES,
            BUNDLED_INDEX.to_vec(),
        )
    }

    fn start_with_index(
        paths: McPaths,
        eligible: bool,
        version: &'static str,
        notes: &'static str,
        index: Vec<(&'static str, usize, usize)>,
    ) -> Self {
        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 || {
                // Record even ineligible launches; unreadable settings are never repaired.
                let unread = release_notes::record_launch(&paths, version)?;
                let selected = eligible
                    .then(|| select_notes(notes, &index, &unread, version))
                    .flatten()
                    .filter(|notes| !notes.trim().is_empty());
                if loaded_tx.send(selected).is_ok()
                    && selected.is_some()
                    && 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 dismiss_restart_welcome(&mut self, state: &mut MissionControlState) {
        if !state.update_restart_welcome {
            return;
        }
        // Deliver only a previous successful full draw, never the dismissal itself.
        if state.release_notes_displayed.get()
            && let Some(sender) = self.displayed.take()
        {
            let _ = sender.try_send(());
        }
        self.loaded = None;
        self.displayed = None;
        state.update_restart_welcome = false;
        state.release_notes = None;
        state.release_notes_drawn.set(false);
        state.release_notes_area.set(Default::default());
    }

    pub(super) fn dismiss_key(
        &mut self,
        key: crossterm::event::KeyEvent,
        state: &mut MissionControlState,
    ) -> bool {
        use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
        if key.kind == KeyEventKind::Release
            || key.code != KeyCode::Esc
            || key.modifiers != KeyModifiers::NONE
            || !shows_restart_welcome(state)
            || state.right_column_tab == super::state::RightColumnTab::Diff
            || state.conversation_modal.is_some()
            || state.modal_covers_transcript()
            || state.autocomplete.is_some()
            || state.toast.is_some()
        {
            return false;
        }
        self.dismiss_restart_welcome(state);
        true
    }

    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.update_restart_welcome)
                {
                    state.release_notes = notes;
                    return true;
                }
                self.displayed = None;
                state.update_restart_welcome = false;
            }
            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()
        && state.release_notes_offset.get() <= state.release_notes_viewed.get()
    {
        let end = state.release_notes_page_end.get();
        state
            .release_notes_viewed
            .set(state.release_notes_viewed.get().max(end));
        if state.release_notes_viewed.get() == 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() || shows_restart_welcome(state))
        && !state.transcript_only_layout
        && state.right_column_tab != super::state::RightColumnTab::Diff
        && state.conversation_modal.is_none()
        && !state.modal_covers_transcript()
        && 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 {
        offset.saturating_add(usize::from(rows))
    } else {
        offset.saturating_sub(usize::from(rows))
    }
    .min(maximum);
    state.release_notes_offset.set(next);
    next != offset
}