travelagent 1.11.1

Agent-first TUI code review tool
//! AI-summary panel state bundle, extracted from `App` in v1.6 and
//! encapsulated in v1.7.2.
//!
//! Six co-moving fields track the lifecycle of an agent-written
//! markdown summary shown in an overlay panel: the summary text
//! itself, its freshness metadata, the unread flag, and the panel's
//! open/scroll state. All six get touched on every
//! `trv_set_ai_summary` MCP call and on every panel open/close.
//!
//! Encapsulation pass (v1.7.2): the mutation helpers that only touch
//! these six fields now live on `AiSummaryState` (`set`, `clear`,
//! `toggle_panel`, `scroll_down`, `scroll_up`). App-level methods on
//! `App` in `app/ai_summary.rs` (`set_ai_summary`, `clear_ai_summary`,
//! etc.) still exist as thin delegations — they're the stable
//! public API for `App`, but their bodies are now one-liners.
//! `ai_summary_staleness` and `current_diff_sha` stay on `App`
//! because they also reach into `engine` and `diff_source`.
//!
//! Distinct from `app::ai_summary` (which houses the `AiSummaryStaleness`
//! enum and related MCP DTO helpers): that module is public API; this
//! module is App-internal state.

/// See module docs for the cluster rationale.
#[derive(Debug, Default)]
pub struct AiSummaryState {
    /// Markdown summary written by a connected AI agent via MCP
    /// (`trv_set_ai_summary`).
    pub summary: Option<String>,
    /// Timestamp (UTC) of the last `trv_set_ai_summary` call. None
    /// if never set.
    pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
    /// Fingerprint of the diff the summary was written for: in
    /// remote PR mode the PR head SHA; in local mode a SHA-256 over
    /// the current diff text. Used to flag the summary as stale
    /// when the diff advances past what the agent saw when writing
    /// the summary. `None` when no summary is set.
    pub diff_sha: Option<String>,
    /// True after the summary was updated but before the user
    /// opened the panel.
    pub unread: bool,
    /// True while the AI summary panel is open.
    pub show_panel: bool,
    /// Scroll offset within the AI summary panel.
    pub scroll: usize,
}

impl AiSummaryState {
    /// Store a new markdown summary. Stamps update time, resets scroll
    /// to the top, and marks unread only when the panel is currently
    /// closed (so typing while reading doesn't distract). Also
    /// records the diff sha the summary was written for so a later
    /// render can flag the summary as stale when the diff advances.
    pub fn set(&mut self, markdown: String, diff_sha: Option<String>) {
        self.summary = Some(markdown);
        self.updated_at = Some(chrono::Utc::now());
        self.diff_sha = diff_sha;
        self.unread = !self.show_panel;
        self.scroll = 0;
    }

    /// Drop the summary and close the panel if it was open.
    pub fn clear(&mut self) {
        self.summary = None;
        self.updated_at = None;
        self.diff_sha = None;
        self.unread = false;
        self.scroll = 0;
        self.show_panel = false;
    }

    /// Toggle the panel open/closed. Opening the panel clears the
    /// unread flag so the reviewer doesn't see a stale indicator.
    pub fn toggle_panel(&mut self) {
        self.show_panel = !self.show_panel;
        if self.show_panel {
            self.unread = false;
        }
    }

    /// Saturating scroll downward by `lines`.
    pub fn scroll_down(&mut self, lines: usize) {
        self.scroll = self.scroll.saturating_add(lines);
    }

    /// Saturating scroll upward by `lines`.
    pub fn scroll_up(&mut self, lines: usize) {
        self.scroll = self.scroll.saturating_sub(lines);
    }
}

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

    #[test]
    fn set_stamps_timestamp_and_sets_unread_when_panel_closed() {
        let mut s = AiSummaryState::default();
        s.set("hello".into(), Some("sha123".into()));
        assert_eq!(s.summary.as_deref(), Some("hello"));
        assert!(s.updated_at.is_some());
        assert_eq!(s.diff_sha.as_deref(), Some("sha123"));
        assert!(s.unread, "panel was closed → should mark unread");
        assert_eq!(s.scroll, 0);
    }

    #[test]
    fn set_leaves_unread_false_when_panel_is_open() {
        let mut s = AiSummaryState {
            show_panel: true,
            ..Default::default()
        };
        s.set("hi".into(), None);
        assert!(!s.unread, "panel was open → no new unread badge");
    }

    #[test]
    fn clear_resets_every_field() {
        let mut s = AiSummaryState::default();
        s.set("x".into(), Some("sha".into()));
        s.show_panel = true;
        s.scroll = 42;
        s.clear();
        assert!(s.summary.is_none());
        assert!(s.updated_at.is_none());
        assert!(s.diff_sha.is_none());
        assert!(!s.unread);
        assert!(!s.show_panel);
        assert_eq!(s.scroll, 0);
    }

    #[test]
    fn toggle_panel_clears_unread_on_open() {
        let mut s = AiSummaryState {
            unread: true,
            show_panel: false,
            ..Default::default()
        };
        s.toggle_panel();
        assert!(s.show_panel);
        assert!(!s.unread, "opening the panel must clear unread");
        s.toggle_panel();
        assert!(!s.show_panel);
    }

    #[test]
    fn scroll_is_saturating() {
        let mut s = AiSummaryState::default();
        s.scroll_up(5); // underflow → stay at 0
        assert_eq!(s.scroll, 0);
        s.scroll_down(3);
        assert_eq!(s.scroll, 3);
        s.scroll_down(usize::MAX); // overflow → saturate
        assert_eq!(s.scroll, usize::MAX);
    }
}