magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use crate::summarizer::SummarySnapshot;
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Paragraph, Wrap},
};

#[derive(Debug, Default)]
pub(crate) struct SummaryDisplay {
    pub(crate) session_id: Option<String>,
    pub(crate) enabled: bool,
    pub(crate) running: bool,
    pub(crate) entries: Vec<String>,
    pub(crate) error: Option<String>,
    /// `u16::MAX` follows the bottom at the current viewport size until manually scrolled.
    pub(crate) scroll: u16,
    pub(crate) requested_enabled: Option<bool>,
}

impl SummaryDisplay {
    /// Keep toggles based on submitted intent until the current worker confirms it.
    pub(crate) fn take_requested_enabled(&mut self) -> Option<bool> {
        let enabled = self.requested_enabled.take()?;
        self.enabled = enabled;
        Some(enabled)
    }

    pub(crate) fn apply(&mut self, snapshot: SummarySnapshot) {
        let mut entries: Vec<_> = snapshot
            .entries
            .into_iter()
            .rev()
            .take(128)
            .map(|entry| crate::tui::activity::sanitize_and_bound_preview(&entry))
            .collect();
        entries.reverse();
        if self.session_id.as_deref() != Some(snapshot.session_id.as_str())
            || self.entries != entries
        {
            self.scroll = u16::MAX;
        }
        self.session_id = Some(snapshot.session_id);
        self.enabled = snapshot.enabled;
        self.running = snapshot.running;
        self.entries = entries;
        self.error = snapshot
            .error
            .map(|text| crate::tui::activity::sanitize_and_bound_preview(&text));
    }

    pub(crate) fn text(&self) -> Text<'_> {
        let heading_style = Style::default().add_modifier(Modifier::BOLD);
        let mut lines = Vec::new();
        if let Some(error) = &self.error {
            lines.push(Line::styled("Error:", heading_style));
            lines.extend(error.split('\n').map(Line::raw));
            lines.push(Line::default());
        }
        if self.entries.is_empty() {
            lines.push(Line::raw("No summary yet."));
        } else {
            for (index, entry) in self.entries.iter().enumerate() {
                if index > 0 {
                    lines.push(Line::default());
                }
                let latest = if index + 1 == self.entries.len() {
                    " · Latest"
                } else {
                    ""
                };
                lines.push(Line::from(Span::styled(
                    format!("── Entry {}{latest} ──", index + 1),
                    heading_style,
                )));
                lines.extend(entry.split('\n').map(Line::raw));
            }
        }
        Text::from(lines)
    }

    pub(crate) fn max_scroll(&self, width: u16, height: u16) -> u16 {
        Paragraph::new(self.text())
            .wrap(Wrap { trim: false })
            .line_count(width.max(1))
            .saturating_sub(usize::from(height))
            .min(usize::from(u16::MAX)) as u16
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};

    fn snapshot(session_id: &str, entries: &[&str]) -> SummarySnapshot {
        SummarySnapshot {
            session_id: session_id.into(),
            enabled: true,
            running: false,
            entries: entries.iter().map(|entry| (*entry).into()).collect(),
            error: None,
        }
    }

    #[test]
    fn new_or_replaced_entries_scroll_to_latest() {
        let mut display = SummaryDisplay::default();
        display.apply(snapshot("session", &["First"]));
        display.scroll = 2;
        display.apply(snapshot("session", &["First", "Second"]));
        assert_eq!(display.scroll, u16::MAX);

        display.scroll = 1;
        display.apply(snapshot("session", &["First", "Revised second"]));
        assert_eq!(display.scroll, u16::MAX);
    }

    #[test]
    fn unchanged_entries_and_status_updates_preserve_scroll() {
        let mut display = SummaryDisplay::default();
        let mut update = snapshot("session", &["First", "Second"]);
        display.apply(update.clone());
        display.scroll = 3;
        display.apply(update.clone());
        assert_eq!(display.scroll, 3);

        update.enabled = false;
        update.running = true;
        update.error = Some("Summary unavailable".into());
        display.apply(update);
        assert_eq!(display.scroll, 3);
        assert!(!display.enabled);
        assert!(display.running);
        assert_eq!(display.error.as_deref(), Some("Summary unavailable"));
    }

    #[test]
    fn sanitized_equivalent_entries_preserve_scroll() {
        let mut display = SummaryDisplay::default();
        display.apply(snapshot("session", &["First"]));
        display.scroll = 2;
        display.apply(snapshot("session", &["\x1b[31mFirst\x1b[0m"]));
        assert_eq!(display.entries, ["First"]);
        assert_eq!(display.scroll, 2);
    }

    #[test]
    fn cap_rollover_scrolls_even_when_entry_count_is_unchanged() {
        let mut display = SummaryDisplay::default();
        let mut update = snapshot("session", &[]);
        update.entries = (0..128).map(|index| format!("Entry {index}")).collect();
        display.apply(update.clone());
        display.scroll = 4;
        update.entries.push("Entry 128".into());
        display.apply(update.clone());
        assert_eq!(display.entries.len(), 128);
        assert_eq!(display.entries.first().unwrap(), "Entry 1");
        assert_eq!(display.entries.last().unwrap(), "Entry 128");
        assert_eq!(display.scroll, u16::MAX);

        display.scroll = 4;
        update.entries[0] = "Changed discarded entry".into();
        display.apply(update);
        assert_eq!(display.scroll, 4);
    }

    #[test]
    fn switching_sessions_scrolls_even_with_identical_entries() {
        let mut display = SummaryDisplay::default();
        display.apply(snapshot("first", &["Same content"]));
        display.scroll = 1;
        display.apply(snapshot("second", &["Same content"]));
        assert_eq!(display.session_id.as_deref(), Some("second"));
        assert_eq!(display.scroll, u16::MAX);
    }

    #[test]
    fn projection_labels_entries_and_preserves_body_line_gaps() {
        let mut display = SummaryDisplay::default();
        display.apply(snapshot("session", &["First\n\nDetail", "Second"]));
        let text = display.text();
        assert_eq!(
            text.to_string(),
            "── Entry 1 ──\nFirst\n\nDetail\n\n── Entry 2 · Latest ──\nSecond"
        );
        for index in [0, 5] {
            assert!(
                text.lines[index].spans[0]
                    .style
                    .add_modifier
                    .contains(Modifier::BOLD)
            );
        }
    }

    #[test]
    fn error_is_labeled_alongside_empty_state() {
        let mut display = SummaryDisplay::default();
        let mut update = snapshot("session", &[]);
        update.error = Some("Unavailable\nTry again".into());
        display.apply(update);
        assert_eq!(
            display.text().to_string(),
            "Error:\nUnavailable\nTry again\n\nNo summary yet."
        );
    }

    #[test]
    fn scroll_limit_matches_wrapped_projection_at_content_width() {
        let mut display = SummaryDisplay::default();
        display.apply(snapshot("session", &["abcdefghijklmnopqrstuvwxyz"]));
        assert_eq!(display.max_scroll(26, 2), 0);
        assert_eq!(display.max_scroll(13, 2), 2);
        assert_eq!(display.max_scroll(13, 10), 0);
        assert_eq!(display.max_scroll(0, 2), display.max_scroll(1, 2));

        let area = Rect::new(0, 0, 13, 2);
        let mut buffer = Buffer::empty(area);
        Paragraph::new(display.text())
            .wrap(Wrap { trim: false })
            .scroll((display.max_scroll(area.width, area.height), 0))
            .render(area, &mut buffer);
        assert_eq!(
            buffer,
            Buffer::with_lines(["abcdefghijklm", "nopqrstuvwxyz"])
        );
    }
}