a73x

e24a78c9

Add TUI patch detail view with revision-aware diff

a73x   2026-03-21 19:38

Navigate from issue detail to linked patch detail via 'p' key.
Shows patch metadata, revision list, reviews, inline comments,
thread comments, and scrollable diff with syntax coloring.
Supports revision cycling ([/]), interdiff toggle (d), and
staleness warning display. Esc returns to issue detail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/lib.rs b/src/lib.rs
index 223d0e4..2d11ea4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -23,7 +23,7 @@ use git2::Repository;
use state::{IssueStatus, PatchStatus};

/// Check if the reviewer's base ref has moved ahead of the patch's latest revision.
fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> {
pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option<String> {
    let latest_commit = &patch.revisions.last()?.commit;
    let commit_oid = git2::Oid::from_str(latest_commit).ok()?;
    let base_ref = format!("refs/heads/{}", patch.base_ref);
diff --git a/src/tui/events.rs b/src/tui/events.rs
index 3171aaf..b095aea 100644
--- a/src/tui/events.rs
+++ b/src/tui/events.rs
@@ -10,6 +10,7 @@ use ratatui::widgets::ListState;

use crate::error::Error;
use crate::issue as issue_mod;
use crate::patch as patch_mod;

use super::state::{App, InputMode, KeyAction, ViewMode};
use super::widgets::ui;
@@ -197,7 +198,30 @@ pub(crate) fn run_loop(

                match app.handle_key(key.code, key.modifiers) {
                    KeyAction::Quit => return Ok(()),
                    KeyAction::Reload if app.mode == ViewMode::PatchDetail => {
                        // Regenerate diff for current revision/interdiff state
                        regenerate_patch_diff(app, repo);
                    }
                    KeyAction::Reload => app.reload(repo),
                    KeyAction::OpenPatchDetail => {
                        if let Some(patch) = app.linked_patch_for_selected().cloned() {
                            // Check staleness
                            let warning = crate::staleness_warning(repo, &patch);
                            app.patch_revision_idx = patch.revisions.len().saturating_sub(1);
                            app.patch_interdiff_mode = false;
                            app.patch_scroll = 0;

                            // Generate initial diff (latest revision vs base)
                            let diff = generate_patch_diff_for(repo, &patch, app.patch_revision_idx, false);
                            app.patch_diff = diff;
                            app.current_patch = Some(patch);
                            app.mode = ViewMode::PatchDetail;

                            if let Some(w) = warning {
                                app.status_msg = Some(w);
                            }
                        }
                    }
                    KeyAction::OpenCommitBrowser => {
                        if let Some(ref_name) = app.selected_ref_name() {
                            match crate::dag::walk_events(repo, &ref_name) {
@@ -223,3 +247,43 @@ pub(crate) fn run_loop(
        }
    }
}

fn generate_patch_diff_for(
    repo: &Repository,
    patch: &crate::state::PatchState,
    rev_idx: usize,
    interdiff_mode: bool,
) -> String {
    if patch.revisions.is_empty() {
        return "(no revisions)".to_string();
    }

    let rev = &patch.revisions[rev_idx];

    if interdiff_mode && rev_idx > 0 {
        let from_rev = patch.revisions[rev_idx - 1].number;
        let to_rev = rev.number;
        match patch_mod::interdiff(repo, patch, from_rev, to_rev) {
            Ok(d) => d,
            Err(e) => format!("(error generating interdiff: {})", e),
        }
    } else {
        // Diff at specific revision vs base
        match patch_mod::diff(repo, &patch.id, Some(rev.number), None) {
            Ok(d) => d,
            Err(e) => format!("(error generating diff: {})", e),
        }
    }
}

fn regenerate_patch_diff(app: &mut App, repo: &Repository) {
    if let Some(ref patch) = app.current_patch {
        let diff = generate_patch_diff_for(
            repo,
            patch,
            app.patch_revision_idx,
            app.patch_interdiff_mode,
        );
        app.patch_diff = diff;
    }
}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index bf73cc5..51f03d6 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -1019,4 +1019,368 @@ mod tests {
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Error loading events");
    }

    // ── Patch detail view tests ─────────────────────────────────────────

    fn make_patch_with_revisions() -> PatchState {
        use crate::state::Revision;
        PatchState {
            id: "deadbeef".into(),
            title: "Fix the thing".into(),
            body: "Detailed description".into(),
            status: PatchStatus::Open,
            base_ref: "main".into(),
            fixes: Some("i1".into()),
            branch: "feature/fix-thing".into(),
            comments: vec![crate::state::Comment {
                author: make_author(),
                body: "Thread comment".into(),
                timestamp: "2026-01-05T00:00:00Z".into(),
                commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(),
            }],
            inline_comments: vec![crate::state::InlineComment {
                author: make_author(),
                file: "src/main.rs".into(),
                line: 42,
                body: "Nit: rename this".into(),
                timestamp: "2026-01-03T00:00:00Z".into(),
                revision: Some(1),
            }],
            reviews: vec![crate::state::Review {
                author: make_author(),
                verdict: ReviewVerdict::Approve,
                body: "LGTM".into(),
                timestamp: "2026-01-04T00:00:00Z".into(),
                revision: Some(2),
            }],
            revisions: vec![
                Revision {
                    number: 1,
                    commit: "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111".into(),
                    tree: "bbbb1111bbbb1111bbbb1111bbbb1111bbbb1111".into(),
                    body: None,
                    timestamp: "2026-01-01T00:00:00Z".into(),
                },
                Revision {
                    number: 2,
                    commit: "aaaa2222aaaa2222aaaa2222aaaa2222aaaa2222".into(),
                    tree: "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222".into(),
                    body: Some("Addressed review comments".into()),
                    timestamp: "2026-01-02T00:00:00Z".into(),
                },
            ],
            created_at: "2026-01-01T00:00:00Z".into(),
            last_updated: "2026-01-04T00:00:00Z".into(),
            author: make_author(),
        }
    }

    #[test]
    fn test_p_key_opens_patch_detail_when_linked_patch_exists() {
        let mut app = test_app();
        // Issue i1 has a linked patch (p1 has fixes=None, so we need to set it)
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::Detail;
        app.list_state.select(Some(0)); // selects issue i1

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::OpenPatchDetail);
    }

    #[test]
    fn test_p_key_noop_when_no_linked_patch() {
        let mut app = test_app();
        // No patches fix any issues by default
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_p_key_noop_in_item_list_pane() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::ItemList;
        app.list_state.select(Some(0));

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_patch_detail_esc_returns_to_details() {
        let mut app = test_app();
        app.current_patch = Some(make_patch_with_revisions());
        app.mode = ViewMode::PatchDetail;
        app.patch_scroll = 5;
        app.patch_revision_idx = 1;
        app.patch_interdiff_mode = true;
        app.patch_diff = "some diff".into();

        let result = app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.current_patch.is_none());
        assert!(app.patch_diff.is_empty());
        assert_eq!(app.patch_scroll, 0);
        assert_eq!(app.patch_revision_idx, 0);
        assert!(!app.patch_interdiff_mode);
    }

    #[test]
    fn test_patch_detail_q_quits() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('q'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Quit);
    }

    #[test]
    fn test_patch_detail_scroll() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);
        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 2);
        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);
    }

    #[test]
    fn test_patch_detail_page_scroll() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.patch_scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::PageDown,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 20);
        app.handle_key(
            crossterm::event::KeyCode::PageUp,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 0);
    }

    #[test]
    fn test_patch_detail_revision_navigation() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 0;

        // Navigate forward
        let result = app.handle_key(
            crossterm::event::KeyCode::Char(']'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert_eq!(app.patch_revision_idx, 1);
        assert_eq!(app.patch_scroll, 0);

        // Can't go past last revision
        let result = app.handle_key(
            crossterm::event::KeyCode::Char(']'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.patch_revision_idx, 1);

        // Navigate backward
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert_eq!(app.patch_revision_idx, 0);

        // Can't go below 0
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.patch_revision_idx, 0);
    }

    #[test]
    fn test_patch_detail_interdiff_toggle() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_interdiff_mode = false;

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert!(app.patch_interdiff_mode);
        assert_eq!(app.patch_scroll, 0);

        let result = app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Reload);
        assert!(!app.patch_interdiff_mode);
    }

    #[test]
    fn test_render_patch_detail() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = "+added line\n-removed line\n context".into();

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Patch Detail");
        assert_buffer_contains(&buf, "Fix the thing");
        assert_buffer_contains(&buf, "deadbeef");
        assert_buffer_contains(&buf, "feature/fix-thing");
    }

    #[test]
    fn test_render_patch_detail_shows_reviews() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = String::new();

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Reviews");
        assert_buffer_contains(&buf, "approve");
        assert_buffer_contains(&buf, "LGTM");
    }

    #[test]
    fn test_render_patch_detail_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
        assert_buffer_contains(&buf, "[/]:revision");
        // "d:interdiff" may be truncated at 80 cols, check prefix
        assert_buffer_contains(&buf, "d:inter");
    }

    #[test]
    fn test_render_patch_detail_no_patch_loaded() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::PatchDetail;
        app.current_patch = None;

        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "No patch loaded");
    }

    #[test]
    fn test_patch_detail_ctrl_c_quits() {
        let mut app = test_app();
        app.mode = ViewMode::PatchDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::CONTROL,
        );
        assert_eq!(result, KeyAction::Quit);
    }

    #[test]
    fn test_full_patch_detail_flow() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        // Press p to open patch detail
        let action = app.handle_key(
            crossterm::event::KeyCode::Char('p'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(action, KeyAction::OpenPatchDetail);

        // Simulate what events.rs does
        app.current_patch = Some(make_patch_with_revisions());
        app.patch_revision_idx = 1;
        app.patch_diff = "diff content".into();
        app.mode = ViewMode::PatchDetail;

        // Navigate revisions
        app.handle_key(
            crossterm::event::KeyCode::Char('['),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_revision_idx, 0);

        // Toggle interdiff
        app.handle_key(
            crossterm::event::KeyCode::Char('d'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert!(app.patch_interdiff_mode);

        // Scroll
        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.patch_scroll, 1);

        // Escape back
        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.current_patch.is_none());
    }

    #[test]
    fn test_linked_patch_for_selected() {
        let mut app = test_app();
        app.patches[0].fixes = Some("i1".into());
        app.list_state.select(Some(0));

        let patch = app.linked_patch_for_selected();
        assert!(patch.is_some());
        assert_eq!(patch.unwrap().title, "Login fix patch");

        // Second issue has no linked patch
        app.list_state.select(Some(1));
        assert!(app.linked_patch_for_selected().is_none());
    }
}
diff --git a/src/tui/state.rs b/src/tui/state.rs
index 527293f..1380918 100644
--- a/src/tui/state.rs
+++ b/src/tui/state.rs
@@ -15,6 +15,7 @@ pub(crate) enum ViewMode {
    Details,
    CommitList,
    CommitDetail,
    PatchDetail,
}

#[derive(Debug, PartialEq)]
@@ -23,6 +24,7 @@ pub(crate) enum KeyAction {
    Quit,
    Reload,
    OpenCommitBrowser,
    OpenPatchDetail,
}

#[derive(Debug, PartialEq, Clone, Copy)]
@@ -73,6 +75,12 @@ pub(crate) struct App {
    pub(crate) status_msg: Option<String>,
    pub(crate) event_history: Vec<(Oid, crate::event::Event)>,
    pub(crate) event_list_state: ListState,
    // Patch detail view state
    pub(crate) current_patch: Option<PatchState>,
    pub(crate) patch_diff: String,
    pub(crate) patch_scroll: u16,
    pub(crate) patch_revision_idx: usize,
    pub(crate) patch_interdiff_mode: bool,
}

impl App {
@@ -96,6 +104,11 @@ impl App {
            status_msg: None,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
            current_patch: None,
            patch_diff: String::new(),
            patch_scroll: 0,
            patch_revision_idx: 0,
            patch_interdiff_mode: false,
        }
    }

@@ -139,7 +152,77 @@ impl App {
        self.scroll = 0;
    }

    /// Find the first linked patch for the currently selected issue.
    pub(crate) fn linked_patch_for_selected(&self) -> Option<&PatchState> {
        let idx = self.list_state.selected()?;
        let visible = self.visible_issues();
        let issue = visible.get(idx)?;
        self.patches
            .iter()
            .find(|p| p.fixes.as_deref() == Some(&issue.id))
    }

    pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
        // Handle PatchDetail mode
        if self.mode == ViewMode::PatchDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::Details;
                    self.current_patch = None;
                    self.patch_diff.clear();
                    self.patch_scroll = 0;
                    self.patch_revision_idx = 0;
                    self.patch_interdiff_mode = false;
                    return KeyAction::Continue;
                }
                KeyCode::Char('q') => return KeyAction::Quit,
                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
                    return KeyAction::Quit;
                }
                KeyCode::Char('j') | KeyCode::Down => {
                    self.patch_scroll = self.patch_scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.patch_scroll = self.patch_scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.patch_scroll = self.patch_scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.patch_scroll = self.patch_scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                KeyCode::Char(']') => {
                    if let Some(ref patch) = self.current_patch {
                        let max = patch.revisions.len().saturating_sub(1);
                        if self.patch_revision_idx < max {
                            self.patch_revision_idx += 1;
                            self.patch_scroll = 0;
                            return KeyAction::Reload; // signal to regenerate diff
                        }
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('[') => {
                    if self.patch_revision_idx > 0 {
                        self.patch_revision_idx -= 1;
                        self.patch_scroll = 0;
                        return KeyAction::Reload; // signal to regenerate diff
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('d') => {
                    self.patch_interdiff_mode = !self.patch_interdiff_mode;
                    self.patch_scroll = 0;
                    return KeyAction::Reload; // signal to regenerate diff
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitDetail mode first
        if self.mode == ViewMode::CommitDetail {
            match code {
@@ -226,6 +309,14 @@ impl App {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('p') => {
                // Open patch detail: only when in detail pane with a linked patch
                if self.pane == Pane::Detail && self.linked_patch_for_selected().is_some() {
                    KeyAction::OpenPatchDetail
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.pane == Pane::ItemList {
                    self.move_selection(1);
diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs
index d514475..40079a1 100644
--- a/src/tui/widgets.rs
+++ b/src/tui/widgets.rs
@@ -2,7 +2,7 @@ use git2::Oid;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};

use crate::event::Action;
use crate::event::{Action, ReviewVerdict};
use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};

use super::state::{App, InputMode, Pane, StatusFilter, ViewMode};
@@ -188,6 +188,21 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
        Style::default().fg(Color::DarkGray)
    };

    // Handle patch detail mode
    if app.mode == ViewMode::PatchDetail {
        let content = build_patch_detail_text(app);
        let block = Block::default()
            .borders(Borders::ALL)
            .title("Patch Detail")
            .border_style(border_style);
        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.patch_scroll, 0));
        frame.render_widget(para, area);
        return;
    }

    // Handle commit browser modes
    if app.mode == ViewMode::CommitList {
        let items: Vec<ListItem> = app
@@ -394,6 +409,245 @@ fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'stati
    Text::from(lines)
}

fn build_patch_detail_text(app: &App) -> Text<'static> {
    let patch = match &app.current_patch {
        Some(p) => p,
        None => return Text::raw("No patch loaded."),
    };

    let status_str = match patch.status {
        PatchStatus::Open => "open",
        PatchStatus::Closed => "closed",
        PatchStatus::Merged => "merged",
    };
    let status_color = match patch.status {
        PatchStatus::Open => Color::Green,
        PatchStatus::Closed => Color::Red,
        PatchStatus::Merged => Color::Cyan,
    };

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", patch.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw("  "),
            Span::styled(status_str, Style::default().fg(status_color)),
        ]),
        Line::from(vec![
            Span::styled("Title:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Branch:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.branch.clone()),
        ]),
        Line::from(vec![
            Span::styled("Base:     ", Style::default().fg(Color::DarkGray)),
            Span::raw(patch.base_ref.clone()),
        ]),
        Line::from(vec![
            Span::styled("Revisions:", Style::default().fg(Color::DarkGray)),
            Span::raw(format!(" {}", patch.revisions.len())),
        ]),
    ];

    if let Some(ref fixes) = patch.fixes {
        lines.push(Line::from(vec![
            Span::styled("Fixes:    ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{:.8}", fixes)),
        ]));
    }

    // Staleness warning (stored in status_msg or computed text)
    if let Some(ref warning) = app.status_msg {
        if warning.contains("behind") {
            lines.push(Line::raw(""));
            lines.push(Line::styled(
                warning.clone(),
                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
            ));
        }
    }

    // Revision list
    if !patch.revisions.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Revisions ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for (i, rev) in patch.revisions.iter().enumerate() {
            let short = if rev.commit.len() >= 8 {
                &rev.commit[..8]
            } else {
                &rev.commit
            };
            let marker = if i == app.patch_revision_idx {
                "> "
            } else {
                "  "
            };
            let label = if i == 0 { " (initial)" } else { "" };
            lines.push(Line::from(vec![
                Span::raw(format!("{}r{}  {}  {}{}", marker, rev.number, short, rev.timestamp, label)),
            ]));
        }
    }

    // Reviews
    if !patch.reviews.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Reviews ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for review in &patch.reviews {
            let verdict_str = match review.verdict {
                ReviewVerdict::Approve => "approve",
                ReviewVerdict::RequestChanges => "request-changes",
                ReviewVerdict::Comment => "comment",
                ReviewVerdict::Reject => "reject",
            };
            let verdict_color = match review.verdict {
                ReviewVerdict::Approve => Color::Green,
                ReviewVerdict::RequestChanges => Color::Yellow,
                ReviewVerdict::Comment => Color::White,
                ReviewVerdict::Reject => Color::Red,
            };
            let rev_label = review
                .revision
                .map(|r| format!(" (r{})", r))
                .unwrap_or_default();
            lines.push(Line::from(vec![
                Span::styled(
                    review.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!(" {} ", review.timestamp)),
                Span::styled(verdict_str, Style::default().fg(verdict_color)),
                Span::raw(rev_label),
            ]));
            if !review.body.is_empty() {
                for l in review.body.lines() {
                    lines.push(Line::raw(format!("  {}", l)));
                }
            }
        }
    }

    // Inline comments
    if !patch.inline_comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Inline Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for ic in &patch.inline_comments {
            let rev_label = ic
                .revision
                .map(|r| format!(" (r{})", r))
                .unwrap_or_default();
            lines.push(Line::from(vec![
                Span::styled(
                    ic.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(format!(" {}:{}", ic.file, ic.line)),
                Span::raw(rev_label),
            ]));
            for l in ic.body.lines() {
                lines.push(Line::raw(format!("  {}", l)));
            }
        }
    }

    // Thread comments
    if !patch.comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for c in &patch.comments {
            lines.push(Line::raw(""));
            lines.push(Line::from(vec![
                Span::styled(
                    c.author.name.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::styled(
                    format!(" ({})", c.timestamp),
                    Style::default().fg(Color::DarkGray),
                ),
            ]));
            for l in c.body.lines() {
                lines.push(Line::raw(format!("  {}", l)));
            }
        }
    }

    // Diff header
    lines.push(Line::raw(""));
    let diff_mode = if app.patch_interdiff_mode {
        let rev_idx = app.patch_revision_idx;
        if rev_idx > 0 {
            let from_rev = patch.revisions.get(rev_idx - 1).map(|r| r.number).unwrap_or(0);
            let to_rev = patch.revisions.get(rev_idx).map(|r| r.number).unwrap_or(0);
            format!("--- Interdiff r{} -> r{} ---", from_rev, to_rev)
        } else {
            "--- Diff vs base (no previous revision) ---".to_string()
        }
    } else {
        let rev_num = patch.revisions.get(app.patch_revision_idx).map(|r| r.number).unwrap_or(1);
        format!("--- Diff r{} vs base ---", rev_num)
    };
    lines.push(Line::styled(
        diff_mode,
        Style::default()
            .fg(Color::Green)
            .add_modifier(Modifier::BOLD),
    ));

    // Diff content
    if app.patch_diff.is_empty() {
        lines.push(Line::raw("(no diff available)"));
    } else {
        for l in app.patch_diff.lines() {
            let style = if l.starts_with('+') {
                Style::default().fg(Color::Green)
            } else if l.starts_with('-') {
                Style::default().fg(Color::Red)
            } else if l.starts_with("@@") {
                Style::default().fg(Color::Cyan)
            } else if l.starts_with("diff ") {
                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            lines.push(Line::styled(l.to_string(), style));
        }
    }

    Text::from(lines)
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    match app.input_mode {
        InputMode::Search => {
@@ -449,7 +703,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    let mode_hint = match app.mode {
        ViewMode::CommitList => "  Esc:back",
        ViewMode::CommitDetail => "  Esc:back  j/k:scroll",
        ViewMode::Details => "  c:events",
        ViewMode::PatchDetail => "  Esc:back  j/k:scroll  [/]:revision  d:interdiff",
        ViewMode::Details => "  c:events  p:patch",
    };
    let filter_hint = match app.status_filter {
        StatusFilter::Open => "a:show all",