a73x

00c285b7

Split tui.rs monolith into submodules for maintainability

a73x   2026-03-21 16:30

Break the 1857-line src/tui.rs into src/tui/ directory with:
- mod.rs: public run() entry point and all tests
- state.rs: App struct, enums (Pane, ViewMode, KeyAction, StatusFilter, InputMode), and key handling
- widgets.rs: all rendering functions (ui, render_list, render_detail, render_footer, etc.)
- events.rs: the main event loop (run_loop) with input mode handling

Public API unchanged: tui::run(&repo) from lib.rs.

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

diff --git a/src/tui.rs b/src/tui.rs
deleted file mode 100644
index 90b04f8..0000000
--- a/src/tui.rs
+++ /dev/null
@@ -1,1857 +0,0 @@
use std::io::{self, stdout};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::{Oid, Repository};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};

use crate::error::Error;
use crate::event::Action;
use crate::issue as issue_mod;
use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

#[derive(Debug, PartialEq)]
enum Pane {
    ItemList,
    Detail,
}

#[derive(Debug, PartialEq)]
enum ViewMode {
    Details,
    CommitList,
    CommitDetail,
}

#[derive(Debug, PartialEq)]
enum KeyAction {
    Continue,
    Quit,
    Reload,
    OpenCommitBrowser,
}

#[derive(Debug, PartialEq, Clone, Copy)]
enum StatusFilter {
    Open,
    Closed,
    All,
}

impl StatusFilter {
    fn next(self) -> Self {
        match self {
            StatusFilter::Open => StatusFilter::All,
            StatusFilter::All => StatusFilter::Closed,
            StatusFilter::Closed => StatusFilter::Open,
        }
    }

    fn label(self) -> &'static str {
        match self {
            StatusFilter::Open => "open",
            StatusFilter::Closed => "closed",
            StatusFilter::All => "all",
        }
    }
}

#[derive(Debug, PartialEq)]
enum InputMode {
    Normal,
    Search,
    CreateTitle,
    CreateBody,
}

struct App {
    issues: Vec<IssueState>,
    patches: Vec<PatchState>,
    list_state: ListState,
    scroll: u16,
    pane: Pane,
    mode: ViewMode,
    status_filter: StatusFilter,
    search_query: String,
    input_mode: InputMode,
    input_buf: String,
    create_title: String,
    status_msg: Option<String>,
    event_history: Vec<(Oid, crate::event::Event)>,
    event_list_state: ListState,
}

impl App {
    fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self {
        let mut list_state = ListState::default();
        if !issues.is_empty() {
            list_state.select(Some(0));
        }
        Self {
            issues,
            patches,
            list_state,
            scroll: 0,
            pane: Pane::ItemList,
            mode: ViewMode::Details,
            status_filter: StatusFilter::Open,
            search_query: String::new(),
            input_mode: InputMode::Normal,
            input_buf: String::new(),
            create_title: String::new(),
            status_msg: None,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
        }
    }

    fn matches_search(&self, title: &str) -> bool {
        if self.search_query.is_empty() {
            return true;
        }
        title
            .to_lowercase()
            .contains(&self.search_query.to_lowercase())
    }

    fn visible_issues(&self) -> Vec<&IssueState> {
        self.issues
            .iter()
            .filter(|i| match self.status_filter {
                StatusFilter::Open => i.status == IssueStatus::Open,
                StatusFilter::Closed => i.status == IssueStatus::Closed,
                StatusFilter::All => true,
            })
            .filter(|i| self.matches_search(&i.title))
            .collect()
    }

    fn visible_count(&self) -> usize {
        self.visible_issues().len()
    }

    fn move_selection(&mut self, delta: i32) {
        let len = self.visible_count();
        if len == 0 {
            return;
        }
        let current = self.list_state.selected().unwrap_or(0);
        let new = if delta > 0 {
            (current + delta as usize).min(len - 1)
        } else {
            current.saturating_sub((-delta) as usize)
        };
        self.list_state.select(Some(new));
        self.scroll = 0;
    }

    fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
        // Handle CommitDetail mode first
        if self.mode == ViewMode::CommitDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::CommitList;
                    self.scroll = 0;
                    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.scroll = self.scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.scroll = self.scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.scroll = self.scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.scroll = self.scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitList mode
        if self.mode == ViewMode::CommitList {
            match code {
                KeyCode::Esc => {
                    self.event_history.clear();
                    self.event_list_state = ListState::default();
                    self.mode = ViewMode::Details;
                    self.scroll = 0;
                    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 => {
                    let len = self.event_history.len();
                    if len > 0 {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = (current + 1).min(len - 1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    if !self.event_history.is_empty() {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = current.saturating_sub(1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Enter => {
                    if self.event_list_state.selected().is_some() {
                        self.mode = ViewMode::CommitDetail;
                        self.scroll = 0;
                    }
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Normal Details mode handling
        match code {
            KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit,
            KeyCode::Char('c') => {
                // Open commit browser: only when in detail pane with an item selected
                if self.pane == Pane::Detail && self.list_state.selected().is_some() {
                    KeyAction::OpenCommitBrowser
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.pane == Pane::ItemList {
                    self.move_selection(1);
                } else {
                    self.scroll = self.scroll.saturating_add(1);
                }
                KeyAction::Continue
            }
            KeyCode::Char('k') | KeyCode::Up => {
                if self.pane == Pane::ItemList {
                    self.move_selection(-1);
                } else {
                    self.scroll = self.scroll.saturating_sub(1);
                }
                KeyAction::Continue
            }
            KeyCode::PageDown => {
                self.scroll = self.scroll.saturating_add(20);
                KeyAction::Continue
            }
            KeyCode::PageUp => {
                self.scroll = self.scroll.saturating_sub(20);
                KeyAction::Continue
            }
            KeyCode::Tab | KeyCode::Enter => {
                self.pane = match self.pane {
                    Pane::ItemList => Pane::Detail,
                    Pane::Detail => Pane::ItemList,
                };
                KeyAction::Continue
            }
            KeyCode::Char('a') => {
                self.status_filter = self.status_filter.next();
                let count = self.visible_count();
                self.list_state
                    .select(if count > 0 { Some(0) } else { None });
                KeyAction::Continue
            }
            KeyCode::Char('r') => KeyAction::Reload,
            _ => KeyAction::Continue,
        }
    }

    fn selected_item_id(&self) -> Option<String> {
        let idx = self.list_state.selected()?;
        let visible = self.visible_issues();
        visible.get(idx).map(|i| i.id.clone())
    }

    fn selected_ref_name(&self) -> Option<String> {
        let id = self.selected_item_id()?;
        Some(format!("refs/collab/issues/{}", id))
    }

    fn reload(&mut self, repo: &Repository) {
        if let Ok(issues) = state::list_issues(repo) {
            self.issues = issues;
        }
        if let Ok(patches) = state::list_patches(repo) {
            self.patches = patches;
        }
        let visible_len = self.visible_count();
        if let Some(sel) = self.list_state.selected() {
            if sel >= visible_len {
                self.list_state.select(if visible_len > 0 {
                    Some(visible_len - 1)
                } else {
                    None
                });
            }
        }
    }
}

fn action_type_label(action: &Action) -> &str {
    match action {
        Action::IssueOpen { .. } => "Issue Open",
        Action::IssueComment { .. } => "Issue Comment",
        Action::IssueClose { .. } => "Issue Close",
        Action::IssueReopen => "Issue Reopen",
        Action::PatchCreate { .. } => "Patch Create",
        Action::PatchRevise { .. } => "Patch Revise",
        Action::PatchReview { .. } => "Patch Review",
        Action::PatchComment { .. } => "Patch Comment",
        Action::PatchInlineComment { .. } => "Inline Comment",
        Action::PatchClose { .. } => "Patch Close",
        Action::PatchMerge => "Patch Merge",
        Action::Merge => "Merge",
        Action::IssueEdit { .. } => "Issue Edit",
        Action::IssueLabel { .. } => "Issue Label",
        Action::IssueUnlabel { .. } => "Issue Unlabel",
        Action::IssueAssign { .. } => "Issue Assign",
        Action::IssueUnassign { .. } => "Issue Unassign",
    }
}

fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String {
    let short_oid = &oid.to_string()[..7];
    let action_label = action_type_label(&event.action);

    let mut detail = format!(
        "Commit:  {}\nAuthor:  {} <{}>\nDate:    {}\nType:    {}\n",
        short_oid, event.author.name, event.author.email, event.timestamp, action_label,
    );

    // Action-specific payload
    match &event.action {
        Action::IssueOpen { title, body } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueComment { body } | Action::PatchComment { body } => {
            detail.push_str(&format!("\n{}\n", body));
        }
        Action::IssueClose { reason } | Action::PatchClose { reason } => {
            if let Some(r) = reason {
                detail.push_str(&format!("\nReason: {}\n", r));
            }
        }
        Action::PatchCreate {
            title,
            body,
            base_ref,
            branch,
            ..
        } => {
            detail.push_str(&format!("\nTitle:  {}\n", title));
            detail.push_str(&format!("Base:   {}\n", base_ref));
            detail.push_str(&format!("Branch: {}\n", branch));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchRevise { body } => {
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\n{}\n", b));
                }
            }
        }
        Action::PatchReview { verdict, body } => {
            detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchInlineComment { file, line, body } => {
            detail.push_str(&format!("\nFile: {}:{}\n", file, line));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueEdit { title, body } => {
            if let Some(t) = title {
                detail.push_str(&format!("\nNew Title: {}\n", t));
            }
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\nNew Body: {}\n", b));
                }
            }
        }
        Action::IssueLabel { label } => {
            detail.push_str(&format!("\nLabel: {}\n", label));
        }
        Action::IssueUnlabel { label } => {
            detail.push_str(&format!("\nRemoved Label: {}\n", label));
        }
        Action::IssueAssign { assignee } => {
            detail.push_str(&format!("\nAssignee: {}\n", assignee));
        }
        Action::IssueUnassign { assignee } => {
            detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
        }
        Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
    }

    detail
}

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = state::list_issues(repo)?;
    let patches = state::list_patches(repo)?;

    let mut app = App::new(issues, patches);

    terminal::enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;

    let result = run_loop(&mut terminal, &mut app, repo);

    terminal::disable_raw_mode()?;
    stdout().execute(LeaveAlternateScreen)?;

    result
}

fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    repo: &Repository,
) -> Result<(), Error> {
    loop {
        terminal.draw(|frame| ui(frame, app))?;

        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                app.status_msg = None; // clear status on any keypress

                // Input mode intercept — handle keys before normal bindings
                match app.input_mode {
                    InputMode::Search => {
                        match key.code {
                            KeyCode::Esc => {
                                app.input_mode = InputMode::Normal;
                                app.search_query.clear();
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            KeyCode::Backspace => {
                                app.search_query.pop();
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            KeyCode::Char(c) => {
                                app.search_query.push(c);
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::CreateTitle => {
                        match key.code {
                            KeyCode::Esc => {
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                            }
                            KeyCode::Enter => {
                                let title = app.input_buf.trim().to_string();
                                if title.is_empty() {
                                    app.input_mode = InputMode::Normal;
                                    app.input_buf.clear();
                                } else {
                                    app.create_title = title;
                                    app.input_buf.clear();
                                    app.input_mode = InputMode::CreateBody;
                                }
                            }
                            KeyCode::Backspace => {
                                app.input_buf.pop();
                            }
                            KeyCode::Char(c) => {
                                app.input_buf.push(c);
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::CreateBody => {
                        match key.code {
                            KeyCode::Esc => {
                                // Submit with title only, no body
                                let title = app.create_title.clone();
                                match issue_mod::open(repo, &title, "") {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
                                            Some(format!("Issue created: {:.8}", id));
                                    }
                                    Err(e) => {
                                        app.status_msg =
                                            Some(format!("Error creating issue: {}", e));
                                    }
                                }
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                                app.create_title.clear();
                            }
                            KeyCode::Enter => {
                                let title = app.create_title.clone();
                                let body = app.input_buf.clone();
                                match issue_mod::open(repo, &title, &body) {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
                                            Some(format!("Issue created: {:.8}", id));
                                    }
                                    Err(e) => {
                                        app.status_msg =
                                            Some(format!("Error creating issue: {}", e));
                                    }
                                }
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                                app.create_title.clear();
                            }
                            KeyCode::Backspace => {
                                app.input_buf.pop();
                            }
                            KeyCode::Char(c) => {
                                app.input_buf.push(c);
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::Normal => {}
                }

                // Handle keys that need repo access or are run_loop-specific
                // before delegating to handle_key
                if app.mode == ViewMode::Details {
                    match key.code {
                        KeyCode::Char('/') => {
                            app.input_mode = InputMode::Search;
                            app.search_query.clear();
                            continue;
                        }
                        KeyCode::Char('n') => {
                            app.input_mode = InputMode::CreateTitle;
                            app.input_buf.clear();
                            app.create_title.clear();
                            continue;
                        }
                        KeyCode::Char('o') => {
                            // Check out the relevant commit for local browsing
                            let checkout_target = {
                                let visible = app.visible_issues();
                                app.list_state
                                    .selected()
                                    .and_then(|idx| visible.get(idx))
                                    .and_then(|issue| {
                                        // Try linked patch first
                                        app.patches
                                            .iter()
                                            .find(|p| p.fixes.as_deref() == Some(&issue.id))
                                            .map(|p| p.branch.clone())
                                            // Fall back to closing commit
                                            .or_else(|| {
                                                issue.closed_by.map(|oid| oid.to_string())
                                            })
                                    })
                            };
                            if let Some(head) = checkout_target {
                                // Exit TUI, checkout, and return
                                terminal::disable_raw_mode()?;
                                stdout().execute(LeaveAlternateScreen)?;
                                let status = std::process::Command::new("git")
                                    .args(["checkout", &head])
                                    .status();
                                match status {
                                    Ok(s) if s.success() => {
                                        println!("Checked out commit: {:.8}", head);
                                        println!("Use 'git checkout -' to return.");
                                    }
                                    Ok(s) => {
                                        eprintln!("git checkout exited with {}", s);
                                    }
                                    Err(e) => {
                                        eprintln!("Failed to run git checkout: {}", e);
                                    }
                                }
                                return Ok(());
                            } else {
                                app.status_msg =
                                    Some("No linked patch to check out".to_string());
                            }
                            continue;
                        }
                        _ => {}
                    }
                }

                match app.handle_key(key.code, key.modifiers) {
                    KeyAction::Quit => return Ok(()),
                    KeyAction::Reload => app.reload(repo),
                    KeyAction::OpenCommitBrowser => {
                        if let Some(ref_name) = app.selected_ref_name() {
                            match crate::dag::walk_events(repo, &ref_name) {
                                Ok(events) => {
                                    app.event_history = events;
                                    app.event_list_state = ListState::default();
                                    if !app.event_history.is_empty() {
                                        app.event_list_state.select(Some(0));
                                    }
                                    app.mode = ViewMode::CommitList;
                                    app.scroll = 0;
                                }
                                Err(e) => {
                                    app.status_msg =
                                        Some(format!("Error loading events: {}", e));
                                }
                            }
                        }
                    }
                    KeyAction::Continue => {}
                }
            }
        }
    }
}

fn ui(frame: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(frame.area());

    let main_area = chunks[0];
    let footer_area = chunks[1];

    let panes = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
        .split(main_area);

    render_list(frame, app, panes[0]);
    render_detail(frame, app, panes[1]);
    render_footer(frame, app, footer_area);
}

fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::ItemList {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    let visible = app.visible_issues();
    let items: Vec<ListItem> = visible
        .iter()
        .map(|i| {
            let status = match i.status {
                IssueStatus::Open => "open",
                IssueStatus::Closed => "closed",
            };
            let style = match i.status {
                IssueStatus::Open => Style::default().fg(Color::Green),
                IssueStatus::Closed => Style::default().fg(Color::Red),
            };
            ListItem::new(format!("{:.8}  {:6}  {}", i.id, status, i.title)).style(style)
        })
        .collect();

    let title = format!("Issues ({})", app.status_filter.label());

    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(title)
                .border_style(border_style),
        )
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("> ");

    frame.render_stateful_widget(list, area, &mut app.list_state);
}

fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::Detail {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    // Handle commit browser modes
    if app.mode == ViewMode::CommitList {
        let items: Vec<ListItem> = app
            .event_history
            .iter()
            .map(|(_oid, evt)| {
                let label = action_type_label(&evt.action);
                ListItem::new(format!(
                    "{} | {} | {}",
                    label, evt.author.name, evt.timestamp
                ))
            })
            .collect();

        let list = List::new(items)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Event History")
                    .border_style(border_style),
            )
            .highlight_style(
                Style::default()
                    .bg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, area, &mut app.event_list_state);
        return;
    }

    if app.mode == ViewMode::CommitDetail {
        let content = if let Some(idx) = app.event_list_state.selected() {
            if let Some((oid, evt)) = app.event_history.get(idx) {
                format_event_detail(oid, evt)
            } else {
                "No event selected.".to_string()
            }
        } else {
            "No event selected.".to_string()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title("Event Detail")
            .border_style(border_style);

        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.scroll, 0));

        frame.render_widget(para, area);
        return;
    }

    let visible = app.visible_issues();
    let selected_idx = app.list_state.selected().unwrap_or(0);
    let content: Text = match visible.get(selected_idx) {
        Some(issue) => build_issue_detail(issue, &app.patches),
        None => Text::raw("No matches for current filter."),
    };

    let block = Block::default()
        .borders(Borders::ALL)
        .title("Issue Details")
        .border_style(border_style);

    let para = Paragraph::new(content)
        .block(block)
        .wrap(Wrap { trim: false })
        .scroll((app.scroll, 0));

    frame.render_widget(para, area);
}

fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> {
    let status = match issue.status {
        IssueStatus::Open => "open",
        IssueStatus::Closed => "closed",
    };

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", issue.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" [{}]", status)),
        ]),
        Line::from(vec![
            Span::styled("Title:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.created_at.clone()),
        ]),
    ];

    if !issue.labels.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Labels:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.labels.join(", ")),
        ]));
    }

    if !issue.assignees.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Assign:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.assignees.join(", ")),
        ]));
    }

    if let Some(ref reason) = issue.close_reason {
        lines.push(Line::from(vec![
            Span::styled("Closed:  ", Style::default().fg(Color::Red)),
            Span::raw(reason.clone()),
        ]));
    }

    if let Some(ref oid) = issue.closed_by {
        lines.push(Line::from(vec![
            Span::styled("Commit:  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{:.8}", oid),
                Style::default().fg(Color::Cyan),
            ),
        ]));
    }

    // Show patches that reference this issue via --fixes
    let fixing_patches: Vec<&PatchState> = patches
        .iter()
        .filter(|p| p.fixes.as_deref() == Some(&issue.id))
        .collect();
    if !fixing_patches.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Linked Patches ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for p in &fixing_patches {
            let status = match p.status {
                PatchStatus::Open => ("open", Color::Green),
                PatchStatus::Closed => ("closed", Color::Red),
                PatchStatus::Merged => ("merged", Color::Cyan),
            };
            lines.push(Line::from(vec![
                Span::styled(
                    format!("{:.8}", p.id),
                    Style::default().fg(Color::Yellow),
                ),
                Span::raw("  "),
                Span::styled(status.0, Style::default().fg(status.1)),
                Span::raw(format!("  {}", p.title)),
            ]));
        }
    }

    if !issue.body.is_empty() {
        lines.push(Line::raw(""));
        for l in issue.body.lines() {
            lines.push(Line::raw(l.to_string()));
        }
    }

    if !issue.comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for c in &issue.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)));
            }
        }
    }

    Text::from(lines)
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    match app.input_mode {
        InputMode::Search => {
            let max_query_len = (area.width as usize).saturating_sub(12);
            let display_query = if app.search_query.len() > max_query_len {
                &app.search_query[app.search_query.len() - max_query_len..]
            } else {
                &app.search_query
            };
            let text = format!(" Search: {}_", display_query);
            let style = Style::default().bg(Color::Blue).fg(Color::White);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateTitle => {
            let max_len = (area.width as usize).saturating_sub(22);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Title: {}_", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateBody => {
            let max_len = (area.width as usize).saturating_sub(21);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Body: {}_  (Esc: skip)", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::Normal => {}
    }

    // Show status message if present
    if let Some(ref msg) = app.status_msg {
        let para =
            Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black));
        frame.render_widget(para, area);
        return;
    }

    let mode_hint = match app.mode {
        ViewMode::CommitList => "  Esc:back",
        ViewMode::CommitDetail => "  Esc:back  j/k:scroll",
        ViewMode::Details => "  c:events",
    };
    let filter_hint = match app.status_filter {
        StatusFilter::Open => "a:show all",
        StatusFilter::All => "a:closed",
        StatusFilter::Closed => "a:open only",
    };
    let text = format!(
        " j/k:navigate  Tab:pane  {}{}  /:search  n:new issue  o:checkout  r:refresh  q:quit",
        filter_hint, mode_hint
    );
    let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
    frame.render_widget(para, area);
}

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

    fn make_author() -> Author {
        Author {
            name: "test".into(),
            email: "test@test.com".into(),
        }
    }

    fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState {
        IssueState {
            id: id.into(),
            title: title.into(),
            body: String::new(),
            status,
            close_reason: None,
            closed_by: None,
            labels: vec![],
            assignees: vec![],
            comments: vec![],
            created_at: String::new(),
            author: make_author(),
        }
    }

    fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState {
        PatchState {
            id: id.into(),
            title: title.into(),
            body: String::new(),
            status,
            base_ref: "main".into(),
            fixes: None,
            branch: format!("feature/{}", id),
            comments: vec![],
            inline_comments: vec![],
            reviews: vec![],
            created_at: String::new(),
            author: make_author(),
        }
    }

    fn test_app() -> App {
        let issues = vec![
            make_issue("i1", "Fix login bug", IssueStatus::Open),
            make_issue("i2", "Add dashboard feature", IssueStatus::Open),
            make_issue("i3", "Old closed issue", IssueStatus::Closed),
        ];
        let patches = vec![
            make_patch("p1", "Login fix patch", PatchStatus::Open),
            make_patch("p2", "Dashboard patch", PatchStatus::Closed),
            make_patch("p3", "Merged feature", PatchStatus::Merged),
        ];
        App::new(issues, patches)
    }

    // T010: visible_issues filters by search_query (case-insensitive)
    #[test]
    fn test_visible_issues_text_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::All;
        app.search_query = "login".into();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Fix login bug");
    }

    // T012: visible_issues returns all status-matching items when search_query is empty
    #[test]
    fn test_visible_issues_no_text_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::All;
        app.search_query.clear();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 3);
    }

    // T019: StatusFilter::next() cycles Open → All → Closed → Open
    #[test]
    fn test_status_filter_cycle() {
        assert_eq!(StatusFilter::Open.next(), StatusFilter::All);
        assert_eq!(StatusFilter::All.next(), StatusFilter::Closed);
        assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open);
    }

    // T020: visible_issues returns only closed when status_filter is Closed
    #[test]
    fn test_visible_issues_closed_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Closed;
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Old closed issue");
    }

    // T025: combined status + text filter
    #[test]
    fn test_combined_filters() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Open;
        app.search_query = "login".into();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Fix login bug");

        // Same query with Closed filter should return nothing
        app.status_filter = StatusFilter::Closed;
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 0);
    }

    // T026: Escape clears text filter but preserves status_filter
    #[test]
    fn test_escape_clears_text_preserves_status() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Closed;
        app.input_mode = InputMode::Search;
        app.search_query = "some query".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.search_query.clear();

        assert_eq!(app.status_filter, StatusFilter::Closed);
        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.search_query.is_empty());
    }

    #[test]
    fn test_create_title_mode_clears_on_escape() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "partial title".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.input_buf.clear();

        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_create_title_transitions_to_body() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "My new issue".into();

        // Simulate Enter with non-empty title
        let title = app.input_buf.trim().to_string();
        assert!(!title.is_empty());
        app.create_title = title;
        app.input_buf.clear();
        app.input_mode = InputMode::CreateBody;

        assert_eq!(app.input_mode, InputMode::CreateBody);
        assert_eq!(app.create_title, "My new issue");
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_empty_title_dismissed() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "   ".into();

        // Simulate Enter with whitespace-only title
        let title = app.input_buf.trim().to_string();
        if title.is_empty() {
            app.input_mode = InputMode::Normal;
            app.input_buf.clear();
        }

        assert_eq!(app.input_mode, InputMode::Normal);
    }

    // ── Commit browser test helpers ─────────────────────────────────────

    use crate::event::ReviewVerdict;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;

    fn test_author() -> Author {
        Author {
            name: "Test User".to_string(),
            email: "test@example.com".to_string(),
        }
    }

    fn make_test_issues(n: usize) -> Vec<IssueState> {
        (0..n)
            .map(|i| IssueState {
                id: format!("{:08x}", i),
                title: format!("Issue {}", i),
                body: format!("Body for issue {}", i),
                status: if i % 2 == 0 {
                    IssueStatus::Open
                } else {
                    IssueStatus::Closed
                },
                close_reason: if i % 2 == 1 {
                    Some("done".to_string())
                } else {
                    None
                },
                closed_by: None,
                labels: vec![],
                assignees: vec![],
                comments: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .collect()
    }

    fn make_test_patches(n: usize) -> Vec<PatchState> {
        (0..n)
            .map(|i| PatchState {
                id: format!("p{:07x}", i),
                title: format!("Patch {}", i),
                body: format!("Body for patch {}", i),
                status: if i % 2 == 0 {
                    PatchStatus::Open
                } else {
                    PatchStatus::Closed
                },
                base_ref: "main".to_string(),
                fixes: None,
                branch: format!("feature/p{:07x}", i),
                comments: Vec::new(),
                inline_comments: Vec::new(),
                reviews: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .collect()
    }

    fn make_app(issues: usize, patches: usize) -> App {
        App::new(make_test_issues(issues), make_test_patches(patches))
    }

    fn render_app(app: &mut App) -> Buffer {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, app)).unwrap();
        terminal.backend().buffer().clone()
    }

    fn buffer_to_string(buf: &Buffer) -> String {
        let area = buf.area;
        let mut s = String::new();
        for y in area.y..area.y + area.height {
            for x in area.x..area.x + area.width {
                let cell = buf.cell((x, y)).unwrap();
                s.push_str(cell.symbol());
            }
            s.push('\n');
        }
        s
    }

    fn assert_buffer_contains(buf: &Buffer, expected: &str) {
        let text = buffer_to_string(buf);
        assert!(
            text.contains(expected),
            "expected buffer to contain {:?}, but it was not found in:\n{}",
            expected,
            text
        );
    }

    /// Create sample event history for testing commit browser
    fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> {
        let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();

        vec![
            (
                oid1,
                crate::event::Event {
                    timestamp: "2026-01-01T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueOpen {
                        title: "Test Issue".to_string(),
                        body: "This is the body".to_string(),
                    },
                    clock: 0,
                },
            ),
            (
                oid2,
                crate::event::Event {
                    timestamp: "2026-01-02T00:00:00Z".to_string(),
                    author: Author {
                        name: "Other User".to_string(),
                        email: "other@example.com".to_string(),
                    },
                    action: Action::IssueComment {
                        body: "A comment on the issue".to_string(),
                    },
                    clock: 0,
                },
            ),
            (
                oid3,
                crate::event::Event {
                    timestamp: "2026-01-03T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueClose {
                        reason: Some("fixed".to_string()),
                    },
                    clock: 0,
                },
            ),
        ]
    }

    // ── action_type_label tests ──────────────────────────────────────────

    #[test]
    fn test_action_type_label_issue_open() {
        let action = Action::IssueOpen {
            title: "t".to_string(),
            body: "b".to_string(),
        };
        assert_eq!(action_type_label(&action), "Issue Open");
    }

    #[test]
    fn test_action_type_label_issue_comment() {
        let action = Action::IssueComment {
            body: "b".to_string(),
        };
        assert_eq!(action_type_label(&action), "Issue Comment");
    }

    #[test]
    fn test_action_type_label_issue_close() {
        let action = Action::IssueClose { reason: None };
        assert_eq!(action_type_label(&action), "Issue Close");
    }

    #[test]
    fn test_action_type_label_issue_reopen() {
        assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen");
    }

    #[test]
    fn test_action_type_label_patch_create() {
        let action = Action::PatchCreate {
            title: "t".to_string(),
            body: "b".to_string(),
            base_ref: "main".to_string(),
            branch: "feature/test".to_string(),
            fixes: None,
        };
        assert_eq!(action_type_label(&action), "Patch Create");
    }

    #[test]
    fn test_action_type_label_patch_review() {
        let action = Action::PatchReview {
            verdict: ReviewVerdict::Approve,
            body: "lgtm".to_string(),
        };
        assert_eq!(action_type_label(&action), "Patch Review");
    }

    #[test]
    fn test_action_type_label_inline_comment() {
        let action = Action::PatchInlineComment {
            file: "src/main.rs".to_string(),
            line: 42,
            body: "nit".to_string(),
        };
        assert_eq!(action_type_label(&action), "Inline Comment");
    }

    #[test]
    fn test_action_type_label_merge() {
        assert_eq!(action_type_label(&Action::Merge), "Merge");
    }

    // ── format_event_detail tests ────────────────────────────────────────

    #[test]
    fn test_format_event_detail_issue_open() {
        let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueOpen {
                title: "My Issue".to_string(),
                body: "Description here".to_string(),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("aaaaaaa"));
        assert!(detail.contains("Test User <test@example.com>"));
        assert!(detail.contains("2026-01-01T00:00:00Z"));
        assert!(detail.contains("Issue Open"));
        assert!(detail.contains("Title: My Issue"));
        assert!(detail.contains("Description here"));
    }

    #[test]
    fn test_format_event_detail_issue_close_with_reason() {
        let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-02-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueClose {
                reason: Some("resolved".to_string()),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Issue Close"));
        assert!(detail.contains("Reason: resolved"));
    }

    #[test]
    fn test_format_event_detail_patch_review() {
        let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-03-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::PatchReview {
                verdict: ReviewVerdict::Approve,
                body: "Looks good!".to_string(),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Patch Review"));
        assert!(detail.contains("Approve"));
        assert!(detail.contains("Looks good!"));
    }

    #[test]
    fn test_format_event_detail_short_oid() {
        let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueReopen,
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("1234567"));
        assert!(detail.contains("Commit:  1234567\n"));
    }

    // ── handle_key tests for 'c' key ─────────────────────────────────────

    #[test]
    fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::OpenCommitBrowser);
    }

    #[test]
    fn test_handle_key_c_in_item_list_pane_is_noop() {
        let mut app = make_app(3, 0);
        app.pane = Pane::ItemList;
        app.list_state.select(Some(0));
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_c_no_selection_is_noop() {
        let mut app = make_app(0, 0);
        app.pane = Pane::Detail;
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_ctrl_c_still_quits() {
        let mut app = make_app(3, 0);
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(result, KeyAction::Quit);
    }

    // ── CommitList navigation tests ──────────────────────────────────────

    #[test]
    fn test_commit_list_navigate_down() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_up() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Char('k'), KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_clamp_bottom() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Down, KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(2));
    }

    #[test]
    fn test_commit_list_navigate_clamp_top() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Up, KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(0));
    }

    #[test]
    fn test_commit_list_escape_returns_to_details() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
        assert_eq!(app.event_list_state.selected(), None);
    }

    #[test]
    fn test_commit_list_q_quits() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Quit);
    }

    // ── CommitDetail tests ───────────────────────────────────────────────

    #[test]
    fn test_commit_list_enter_opens_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        let result = app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_commit_list_enter_no_selection_stays() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state = ListState::default();
        app.mode = ViewMode::CommitList;

        app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_commit_detail_escape_returns_to_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        app.scroll = 5;

        let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);
        assert_eq!(app.event_history.len(), 3);
    }

    #[test]
    fn test_commit_detail_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

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

    #[test]
    fn test_commit_detail_page_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

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

    #[test]
    fn test_commit_detail_q_quits() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Quit);
    }

    // ── Guard tests ──────────────────────────────────────────────────────

    #[test]
    fn test_c_ignored_in_commit_list_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_c_ignored_in_commit_detail_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
    }

    // ── handle_key basic tests ───────────────────────────────────────────

    #[test]
    fn test_handle_key_quit() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_esc() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(KeyCode::Esc, KeyModifiers::empty()),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_ctrl_c() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_pane_toggle() {
        let mut app = make_app(3, 3);
        assert_eq!(app.pane, Pane::ItemList);
        app.handle_key(KeyCode::Tab, KeyModifiers::empty());
        assert_eq!(app.pane, Pane::Detail);
        app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(app.pane, Pane::ItemList);
    }

    #[test]
    fn test_scroll_in_detail_pane() {
        let mut app = make_app(3, 0);
        app.list_state.select(Some(0));
        app.pane = Pane::Detail;
        app.scroll = 0;

        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.scroll, 1);
        assert_eq!(app.list_state.selected(), Some(0));

        app.handle_key(KeyCode::Char('k'), KeyModifiers::empty());
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_handle_key_reload() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(KeyCode::Char('r'), KeyModifiers::empty()),
            KeyAction::Reload
        );
    }

    // ── selected_ref_name tests ──────────────────────────────────────────

    #[test]
    fn test_selected_ref_name_issues() {
        let app = make_app(3, 0);
        let ref_name = app.selected_ref_name();
        assert_eq!(
            ref_name,
            Some("refs/collab/issues/00000000".to_string())
        );
    }

    #[test]
    fn test_selected_ref_name_none_when_empty() {
        let app = make_app(0, 0);
        assert_eq!(app.selected_ref_name(), None);
    }

    // ── Render tests ─────────────────────────────────────────────────────

    #[test]
    fn test_render_issues_tab() {
        let mut app = make_app(3, 2);
        app.status_filter = StatusFilter::All;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Issues");
        assert_buffer_contains(&buf, "00000000");
        assert_buffer_contains(&buf, "00000001");
        assert_buffer_contains(&buf, "00000002");
        assert_buffer_contains(&buf, "Issue 0");
    }

    #[test]
    fn test_render_empty_state() {
        let mut app = make_app(0, 0);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "No matches for current filter.");
    }

    #[test]
    fn test_render_footer_keys() {
        let mut app = make_app(3, 3);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "j/k:navigate");
        assert_buffer_contains(&buf, "Tab:pane");
        assert_buffer_contains(&buf, "c:events");
    }

    #[test]
    fn test_render_commit_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event History");
        assert_buffer_contains(&buf, "Issue Open");
        assert_buffer_contains(&buf, "Issue Comment");
        assert_buffer_contains(&buf, "Issue Close");
    }

    #[test]
    fn test_render_commit_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event Detail");
        assert_buffer_contains(&buf, "aaaaaaa");
        assert_buffer_contains(&buf, "Test User");
        assert_buffer_contains(&buf, "Issue Open");
    }

    #[test]
    fn test_render_commit_list_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
    }

    #[test]
    fn test_render_commit_detail_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
        assert_buffer_contains(&buf, "j/k:scroll");
    }

    #[test]
    fn test_render_small_terminal() {
        let mut app = make_app(3, 3);
        let backend = TestBackend::new(20, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, &mut app)).unwrap();
    }

    // ── Integration: full browse flow ────────────────────────────────────

    #[test]
    fn test_full_commit_browse_flow() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        let action = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty());
        assert_eq!(action, KeyAction::OpenCommitBrowser);

        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        app.scroll = 0;

        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.event_list_state.selected(), Some(1));

        app.handle_key(KeyCode::Enter, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitDetail);

        app.handle_key(KeyCode::Char('j'), KeyModifiers::empty());
        assert_eq!(app.scroll, 1);

        app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);

        app.handle_key(KeyCode::Esc, KeyModifiers::empty());
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
    }

    // ── Status message render test ───────────────────────────────────────

    #[test]
    fn test_render_status_message() {
        let mut app = make_app(3, 0);
        app.status_msg = Some("Error loading events: ref not found".to_string());
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Error loading events");
    }
}
diff --git a/src/tui/events.rs b/src/tui/events.rs
new file mode 100644
index 0000000..ff02163
--- /dev/null
+++ b/src/tui/events.rs
@@ -0,0 +1,225 @@
use std::io::{self, stdout};
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode};
use crossterm::terminal::{self, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use ratatui::prelude::*;
use ratatui::widgets::ListState;

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

use super::state::{App, InputMode, KeyAction, ViewMode};
use super::widgets::ui;

pub(crate) fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    repo: &Repository,
) -> Result<(), Error> {
    loop {
        terminal.draw(|frame| ui(frame, app))?;

        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                app.status_msg = None; // clear status on any keypress

                // Input mode intercept — handle keys before normal bindings
                match app.input_mode {
                    InputMode::Search => {
                        match key.code {
                            KeyCode::Esc => {
                                app.input_mode = InputMode::Normal;
                                app.search_query.clear();
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            KeyCode::Backspace => {
                                app.search_query.pop();
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            KeyCode::Char(c) => {
                                app.search_query.push(c);
                                let count = app.visible_count();
                                app.list_state
                                    .select(if count > 0 { Some(0) } else { None });
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::CreateTitle => {
                        match key.code {
                            KeyCode::Esc => {
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                            }
                            KeyCode::Enter => {
                                let title = app.input_buf.trim().to_string();
                                if title.is_empty() {
                                    app.input_mode = InputMode::Normal;
                                    app.input_buf.clear();
                                } else {
                                    app.create_title = title;
                                    app.input_buf.clear();
                                    app.input_mode = InputMode::CreateBody;
                                }
                            }
                            KeyCode::Backspace => {
                                app.input_buf.pop();
                            }
                            KeyCode::Char(c) => {
                                app.input_buf.push(c);
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::CreateBody => {
                        match key.code {
                            KeyCode::Esc => {
                                // Submit with title only, no body
                                let title = app.create_title.clone();
                                match issue_mod::open(repo, &title, "") {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
                                            Some(format!("Issue created: {:.8}", id));
                                    }
                                    Err(e) => {
                                        app.status_msg =
                                            Some(format!("Error creating issue: {}", e));
                                    }
                                }
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                                app.create_title.clear();
                            }
                            KeyCode::Enter => {
                                let title = app.create_title.clone();
                                let body = app.input_buf.clone();
                                match issue_mod::open(repo, &title, &body) {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
                                            Some(format!("Issue created: {:.8}", id));
                                    }
                                    Err(e) => {
                                        app.status_msg =
                                            Some(format!("Error creating issue: {}", e));
                                    }
                                }
                                app.input_mode = InputMode::Normal;
                                app.input_buf.clear();
                                app.create_title.clear();
                            }
                            KeyCode::Backspace => {
                                app.input_buf.pop();
                            }
                            KeyCode::Char(c) => {
                                app.input_buf.push(c);
                            }
                            _ => {}
                        }
                        continue;
                    }
                    InputMode::Normal => {}
                }

                // Handle keys that need repo access or are run_loop-specific
                // before delegating to handle_key
                if app.mode == ViewMode::Details {
                    match key.code {
                        KeyCode::Char('/') => {
                            app.input_mode = InputMode::Search;
                            app.search_query.clear();
                            continue;
                        }
                        KeyCode::Char('n') => {
                            app.input_mode = InputMode::CreateTitle;
                            app.input_buf.clear();
                            app.create_title.clear();
                            continue;
                        }
                        KeyCode::Char('o') => {
                            // Check out the relevant commit for local browsing
                            let checkout_target = {
                                let visible = app.visible_issues();
                                app.list_state
                                    .selected()
                                    .and_then(|idx| visible.get(idx))
                                    .and_then(|issue| {
                                        // Try linked patch first
                                        app.patches
                                            .iter()
                                            .find(|p| p.fixes.as_deref() == Some(&issue.id))
                                            .map(|p| p.branch.clone())
                                            // Fall back to closing commit
                                            .or_else(|| {
                                                issue.closed_by.map(|oid| oid.to_string())
                                            })
                                    })
                            };
                            if let Some(head) = checkout_target {
                                // Exit TUI, checkout, and return
                                terminal::disable_raw_mode()?;
                                stdout().execute(LeaveAlternateScreen)?;
                                let status = std::process::Command::new("git")
                                    .args(["checkout", &head])
                                    .status();
                                match status {
                                    Ok(s) if s.success() => {
                                        println!("Checked out commit: {:.8}", head);
                                        println!("Use 'git checkout -' to return.");
                                    }
                                    Ok(s) => {
                                        eprintln!("git checkout exited with {}", s);
                                    }
                                    Err(e) => {
                                        eprintln!("Failed to run git checkout: {}", e);
                                    }
                                }
                                return Ok(());
                            } else {
                                app.status_msg =
                                    Some("No linked patch to check out".to_string());
                            }
                            continue;
                        }
                        _ => {}
                    }
                }

                match app.handle_key(key.code, key.modifiers) {
                    KeyAction::Quit => return Ok(()),
                    KeyAction::Reload => app.reload(repo),
                    KeyAction::OpenCommitBrowser => {
                        if let Some(ref_name) = app.selected_ref_name() {
                            match crate::dag::walk_events(repo, &ref_name) {
                                Ok(events) => {
                                    app.event_history = events;
                                    app.event_list_state = ListState::default();
                                    if !app.event_history.is_empty() {
                                        app.event_list_state.select(Some(0));
                                    }
                                    app.mode = ViewMode::CommitList;
                                    app.scroll = 0;
                                }
                                Err(e) => {
                                    app.status_msg =
                                        Some(format!("Error loading events: {}", e));
                                }
                            }
                        }
                    }
                    KeyAction::Continue => {}
                }
            }
        }
    }
}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
new file mode 100644
index 0000000..ee95f8b
--- /dev/null
+++ b/src/tui/mod.rs
@@ -0,0 +1,1006 @@
mod events;
mod state;
mod widgets;

use std::io::stdout;

use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::Repository;
use ratatui::prelude::*;

use crate::error::Error;
use crate::state as app_state;

use self::events::run_loop;
use self::state::App;

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = app_state::list_issues(repo)?;
    let patches = app_state::list_patches(repo)?;

    let mut app = App::new(issues, patches);

    terminal::enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;

    let result = run_loop(&mut terminal, &mut app, repo);

    terminal::disable_raw_mode()?;
    stdout().execute(LeaveAlternateScreen)?;

    result
}

#[cfg(test)]
mod tests {
    use super::state::*;
    use super::widgets::*;
    use crate::event::{Action, Author, ReviewVerdict};
    use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus};
    use git2::Oid;
    use ratatui::backend::TestBackend;
    use ratatui::buffer::Buffer;
    use ratatui::prelude::*;
    use ratatui::widgets::ListState;

    fn make_author() -> Author {
        Author {
            name: "test".into(),
            email: "test@test.com".into(),
        }
    }

    fn make_issue(id: &str, title: &str, status: IssueStatus) -> IssueState {
        IssueState {
            id: id.into(),
            title: title.into(),
            body: String::new(),
            status,
            close_reason: None,
            closed_by: None,
            labels: vec![],
            assignees: vec![],
            comments: vec![],
            created_at: String::new(),
            author: make_author(),
        }
    }

    fn make_patch(id: &str, title: &str, status: PatchStatus) -> PatchState {
        PatchState {
            id: id.into(),
            title: title.into(),
            body: String::new(),
            status,
            base_ref: "main".into(),
            fixes: None,
            branch: format!("feature/{}", id),
            comments: vec![],
            inline_comments: vec![],
            reviews: vec![],
            created_at: String::new(),
            author: make_author(),
        }
    }

    fn test_app() -> App {
        let issues = vec![
            make_issue("i1", "Fix login bug", IssueStatus::Open),
            make_issue("i2", "Add dashboard feature", IssueStatus::Open),
            make_issue("i3", "Old closed issue", IssueStatus::Closed),
        ];
        let patches = vec![
            make_patch("p1", "Login fix patch", PatchStatus::Open),
            make_patch("p2", "Dashboard patch", PatchStatus::Closed),
            make_patch("p3", "Merged feature", PatchStatus::Merged),
        ];
        App::new(issues, patches)
    }

    // T010: visible_issues filters by search_query (case-insensitive)
    #[test]
    fn test_visible_issues_text_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::All;
        app.search_query = "login".into();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Fix login bug");
    }

    // T012: visible_issues returns all status-matching items when search_query is empty
    #[test]
    fn test_visible_issues_no_text_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::All;
        app.search_query.clear();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 3);
    }

    // T019: StatusFilter::next() cycles Open -> All -> Closed -> Open
    #[test]
    fn test_status_filter_cycle() {
        assert_eq!(StatusFilter::Open.next(), StatusFilter::All);
        assert_eq!(StatusFilter::All.next(), StatusFilter::Closed);
        assert_eq!(StatusFilter::Closed.next(), StatusFilter::Open);
    }

    // T020: visible_issues returns only closed when status_filter is Closed
    #[test]
    fn test_visible_issues_closed_filter() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Closed;
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Old closed issue");
    }

    // T025: combined status + text filter
    #[test]
    fn test_combined_filters() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Open;
        app.search_query = "login".into();
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].title, "Fix login bug");

        // Same query with Closed filter should return nothing
        app.status_filter = StatusFilter::Closed;
        let visible = app.visible_issues();
        assert_eq!(visible.len(), 0);
    }

    // T026: Escape clears text filter but preserves status_filter
    #[test]
    fn test_escape_clears_text_preserves_status() {
        let mut app = test_app();
        app.status_filter = StatusFilter::Closed;
        app.input_mode = InputMode::Search;
        app.search_query = "some query".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.search_query.clear();

        assert_eq!(app.status_filter, StatusFilter::Closed);
        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.search_query.is_empty());
    }

    #[test]
    fn test_create_title_mode_clears_on_escape() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "partial title".into();

        // Simulate Escape
        app.input_mode = InputMode::Normal;
        app.input_buf.clear();

        assert_eq!(app.input_mode, InputMode::Normal);
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_create_title_transitions_to_body() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "My new issue".into();

        // Simulate Enter with non-empty title
        let title = app.input_buf.trim().to_string();
        assert!(!title.is_empty());
        app.create_title = title;
        app.input_buf.clear();
        app.input_mode = InputMode::CreateBody;

        assert_eq!(app.input_mode, InputMode::CreateBody);
        assert_eq!(app.create_title, "My new issue");
        assert!(app.input_buf.is_empty());
    }

    #[test]
    fn test_empty_title_dismissed() {
        let mut app = test_app();
        app.input_mode = InputMode::CreateTitle;
        app.input_buf = "   ".into();

        // Simulate Enter with whitespace-only title
        let title = app.input_buf.trim().to_string();
        if title.is_empty() {
            app.input_mode = InputMode::Normal;
            app.input_buf.clear();
        }

        assert_eq!(app.input_mode, InputMode::Normal);
    }

    // ── Commit browser test helpers ─────────────────────────────────────

    fn test_author() -> Author {
        Author {
            name: "Test User".to_string(),
            email: "test@example.com".to_string(),
        }
    }

    fn make_test_issues(n: usize) -> Vec<IssueState> {
        (0..n)
            .map(|i| IssueState {
                id: format!("{:08x}", i),
                title: format!("Issue {}", i),
                body: format!("Body for issue {}", i),
                status: if i % 2 == 0 {
                    IssueStatus::Open
                } else {
                    IssueStatus::Closed
                },
                close_reason: if i % 2 == 1 {
                    Some("done".to_string())
                } else {
                    None
                },
                closed_by: None,
                labels: vec![],
                assignees: vec![],
                comments: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .collect()
    }

    fn make_test_patches(n: usize) -> Vec<PatchState> {
        (0..n)
            .map(|i| PatchState {
                id: format!("p{:07x}", i),
                title: format!("Patch {}", i),
                body: format!("Body for patch {}", i),
                status: if i % 2 == 0 {
                    PatchStatus::Open
                } else {
                    PatchStatus::Closed
                },
                base_ref: "main".to_string(),
                fixes: None,
                branch: format!("feature/p{:07x}", i),
                comments: Vec::new(),
                inline_comments: Vec::new(),
                reviews: Vec::new(),
                created_at: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
            })
            .collect()
    }

    fn make_app(issues: usize, patches: usize) -> App {
        App::new(make_test_issues(issues), make_test_patches(patches))
    }

    fn render_app(app: &mut App) -> Buffer {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, app)).unwrap();
        terminal.backend().buffer().clone()
    }

    fn buffer_to_string(buf: &Buffer) -> String {
        let area = buf.area;
        let mut s = String::new();
        for y in area.y..area.y + area.height {
            for x in area.x..area.x + area.width {
                let cell = buf.cell((x, y)).unwrap();
                s.push_str(cell.symbol());
            }
            s.push('\n');
        }
        s
    }

    fn assert_buffer_contains(buf: &Buffer, expected: &str) {
        let text = buffer_to_string(buf);
        assert!(
            text.contains(expected),
            "expected buffer to contain {:?}, but it was not found in:\n{}",
            expected,
            text
        );
    }

    /// Create sample event history for testing commit browser
    fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> {
        let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();

        vec![
            (
                oid1,
                crate::event::Event {
                    timestamp: "2026-01-01T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueOpen {
                        title: "Test Issue".to_string(),
                        body: "This is the body".to_string(),
                    },
                    clock: 0,
                },
            ),
            (
                oid2,
                crate::event::Event {
                    timestamp: "2026-01-02T00:00:00Z".to_string(),
                    author: Author {
                        name: "Other User".to_string(),
                        email: "other@example.com".to_string(),
                    },
                    action: Action::IssueComment {
                        body: "A comment on the issue".to_string(),
                    },
                    clock: 0,
                },
            ),
            (
                oid3,
                crate::event::Event {
                    timestamp: "2026-01-03T00:00:00Z".to_string(),
                    author: test_author(),
                    action: Action::IssueClose {
                        reason: Some("fixed".to_string()),
                    },
                    clock: 0,
                },
            ),
        ]
    }

    // ── action_type_label tests ──────────────────────────────────────────

    #[test]
    fn test_action_type_label_issue_open() {
        let action = Action::IssueOpen {
            title: "t".to_string(),
            body: "b".to_string(),
        };
        assert_eq!(action_type_label(&action), "Issue Open");
    }

    #[test]
    fn test_action_type_label_issue_comment() {
        let action = Action::IssueComment {
            body: "b".to_string(),
        };
        assert_eq!(action_type_label(&action), "Issue Comment");
    }

    #[test]
    fn test_action_type_label_issue_close() {
        let action = Action::IssueClose { reason: None };
        assert_eq!(action_type_label(&action), "Issue Close");
    }

    #[test]
    fn test_action_type_label_issue_reopen() {
        assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen");
    }

    #[test]
    fn test_action_type_label_patch_create() {
        let action = Action::PatchCreate {
            title: "t".to_string(),
            body: "b".to_string(),
            base_ref: "main".to_string(),
            branch: "feature/test".to_string(),
            fixes: None,
        };
        assert_eq!(action_type_label(&action), "Patch Create");
    }

    #[test]
    fn test_action_type_label_patch_review() {
        let action = Action::PatchReview {
            verdict: ReviewVerdict::Approve,
            body: "lgtm".to_string(),
        };
        assert_eq!(action_type_label(&action), "Patch Review");
    }

    #[test]
    fn test_action_type_label_inline_comment() {
        let action = Action::PatchInlineComment {
            file: "src/main.rs".to_string(),
            line: 42,
            body: "nit".to_string(),
        };
        assert_eq!(action_type_label(&action), "Inline Comment");
    }

    #[test]
    fn test_action_type_label_merge() {
        assert_eq!(action_type_label(&Action::Merge), "Merge");
    }

    // ── format_event_detail tests ────────────────────────────────────────

    #[test]
    fn test_format_event_detail_issue_open() {
        let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueOpen {
                title: "My Issue".to_string(),
                body: "Description here".to_string(),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("aaaaaaa"));
        assert!(detail.contains("Test User <test@example.com>"));
        assert!(detail.contains("2026-01-01T00:00:00Z"));
        assert!(detail.contains("Issue Open"));
        assert!(detail.contains("Title: My Issue"));
        assert!(detail.contains("Description here"));
    }

    #[test]
    fn test_format_event_detail_issue_close_with_reason() {
        let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-02-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueClose {
                reason: Some("resolved".to_string()),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Issue Close"));
        assert!(detail.contains("Reason: resolved"));
    }

    #[test]
    fn test_format_event_detail_patch_review() {
        let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-03-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::PatchReview {
                verdict: ReviewVerdict::Approve,
                body: "Looks good!".to_string(),
            },
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("Patch Review"));
        assert!(detail.contains("Approve"));
        assert!(detail.contains("Looks good!"));
    }

    #[test]
    fn test_format_event_detail_short_oid() {
        let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap();
        let event = crate::event::Event {
            timestamp: "2026-01-01T00:00:00Z".to_string(),
            author: test_author(),
            action: Action::IssueReopen,
            clock: 0,
        };
        let detail = format_event_detail(&oid, &event);
        assert!(detail.contains("1234567"));
        assert!(detail.contains("Commit:  1234567\n"));
    }

    // ── handle_key tests for 'c' key ─────────────────────────────────────

    #[test]
    fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::OpenCommitBrowser);
    }

    #[test]
    fn test_handle_key_c_in_item_list_pane_is_noop() {
        let mut app = make_app(3, 0);
        app.pane = Pane::ItemList;
        app.list_state.select(Some(0));
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_c_no_selection_is_noop() {
        let mut app = make_app(0, 0);
        app.pane = Pane::Detail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
    }

    #[test]
    fn test_handle_key_ctrl_c_still_quits() {
        let mut app = make_app(3, 0);
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::CONTROL,
        );
        assert_eq!(result, KeyAction::Quit);
    }

    // ── CommitList navigation tests ──────────────────────────────────────

    #[test]
    fn test_commit_list_navigate_down() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_up() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));
    }

    #[test]
    fn test_commit_list_navigate_clamp_bottom() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(2));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Down,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(2));
    }

    #[test]
    fn test_commit_list_navigate_clamp_top() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Up,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(0));
    }

    #[test]
    fn test_commit_list_escape_returns_to_details() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        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.event_history.is_empty());
        assert_eq!(app.event_list_state.selected(), None);
    }

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

    // ── CommitDetail tests ───────────────────────────────────────────────

    #[test]
    fn test_commit_list_enter_opens_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(1));
        app.mode = ViewMode::CommitList;

        let result = app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_commit_list_enter_no_selection_stays() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state = ListState::default();
        app.mode = ViewMode::CommitList;

        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_commit_detail_escape_returns_to_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        app.scroll = 5;

        let result = app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);
        assert_eq!(app.event_history.len(), 3);
    }

    #[test]
    fn test_commit_detail_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

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

    #[test]
    fn test_commit_detail_page_scroll() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        app.scroll = 0;

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

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

    // ── Guard tests ──────────────────────────────────────────────────────

    #[test]
    fn test_c_ignored_in_commit_list_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitList);
    }

    #[test]
    fn test_c_ignored_in_commit_detail_mode() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let result = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(result, KeyAction::Continue);
        assert_eq!(app.mode, ViewMode::CommitDetail);
    }

    // ── handle_key basic tests ───────────────────────────────────────────

    #[test]
    fn test_handle_key_quit() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('q'),
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_esc() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Esc,
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_quit_ctrl_c() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('c'),
                crossterm::event::KeyModifiers::CONTROL
            ),
            KeyAction::Quit
        );
    }

    #[test]
    fn test_handle_key_pane_toggle() {
        let mut app = make_app(3, 3);
        assert_eq!(app.pane, Pane::ItemList);
        app.handle_key(
            crossterm::event::KeyCode::Tab,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.pane, Pane::Detail);
        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.pane, Pane::ItemList);
    }

    #[test]
    fn test_scroll_in_detail_pane() {
        let mut app = make_app(3, 0);
        app.list_state.select(Some(0));
        app.pane = Pane::Detail;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 1);
        assert_eq!(app.list_state.selected(), Some(0));

        app.handle_key(
            crossterm::event::KeyCode::Char('k'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.scroll, 0);
    }

    #[test]
    fn test_handle_key_reload() {
        let mut app = make_app(3, 3);
        assert_eq!(
            app.handle_key(
                crossterm::event::KeyCode::Char('r'),
                crossterm::event::KeyModifiers::empty()
            ),
            KeyAction::Reload
        );
    }

    // ── selected_ref_name tests ──────────────────────────────────────────

    #[test]
    fn test_selected_ref_name_issues() {
        let app = make_app(3, 0);
        let ref_name = app.selected_ref_name();
        assert_eq!(
            ref_name,
            Some("refs/collab/issues/00000000".to_string())
        );
    }

    #[test]
    fn test_selected_ref_name_none_when_empty() {
        let app = make_app(0, 0);
        assert_eq!(app.selected_ref_name(), None);
    }

    // ── Render tests ─────────────────────────────────────────────────────

    #[test]
    fn test_render_issues_tab() {
        let mut app = make_app(3, 2);
        app.status_filter = StatusFilter::All;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Issues");
        assert_buffer_contains(&buf, "00000000");
        assert_buffer_contains(&buf, "00000001");
        assert_buffer_contains(&buf, "00000002");
        assert_buffer_contains(&buf, "Issue 0");
    }

    #[test]
    fn test_render_empty_state() {
        let mut app = make_app(0, 0);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "No matches for current filter.");
    }

    #[test]
    fn test_render_footer_keys() {
        let mut app = make_app(3, 3);
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "j/k:navigate");
        assert_buffer_contains(&buf, "Tab:pane");
        assert_buffer_contains(&buf, "c:events");
    }

    #[test]
    fn test_render_commit_list() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event History");
        assert_buffer_contains(&buf, "Issue Open");
        assert_buffer_contains(&buf, "Issue Comment");
        assert_buffer_contains(&buf, "Issue Close");
    }

    #[test]
    fn test_render_commit_detail() {
        let mut app = make_app(3, 0);
        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Event Detail");
        assert_buffer_contains(&buf, "aaaaaaa");
        assert_buffer_contains(&buf, "Test User");
        assert_buffer_contains(&buf, "Issue Open");
    }

    #[test]
    fn test_render_commit_list_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitList;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
    }

    #[test]
    fn test_render_commit_detail_footer() {
        let mut app = make_app(3, 0);
        app.mode = ViewMode::CommitDetail;
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Esc:back");
        assert_buffer_contains(&buf, "j/k:scroll");
    }

    #[test]
    fn test_render_small_terminal() {
        let mut app = make_app(3, 3);
        let backend = TestBackend::new(20, 10);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal.draw(|frame| ui(frame, &mut app)).unwrap();
    }

    // ── Integration: full browse flow ────────────────────────────────────

    #[test]
    fn test_full_commit_browse_flow() {
        let mut app = make_app(3, 0);
        app.pane = Pane::Detail;
        app.list_state.select(Some(0));

        let action = app.handle_key(
            crossterm::event::KeyCode::Char('c'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(action, KeyAction::OpenCommitBrowser);

        app.event_history = make_test_event_history();
        app.event_list_state.select(Some(0));
        app.mode = ViewMode::CommitList;
        app.scroll = 0;

        app.handle_key(
            crossterm::event::KeyCode::Char('j'),
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.event_list_state.selected(), Some(1));

        app.handle_key(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitDetail);

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

        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::CommitList);
        assert_eq!(app.scroll, 0);

        app.handle_key(
            crossterm::event::KeyCode::Esc,
            crossterm::event::KeyModifiers::empty(),
        );
        assert_eq!(app.mode, ViewMode::Details);
        assert!(app.event_history.is_empty());
    }

    // ── Status message render test ───────────────────────────────────────

    #[test]
    fn test_render_status_message() {
        let mut app = make_app(3, 0);
        app.status_msg = Some("Error loading events: ref not found".to_string());
        let buf = render_app(&mut app);
        assert_buffer_contains(&buf, "Error loading events");
    }
}
diff --git a/src/tui/state.rs b/src/tui/state.rs
new file mode 100644
index 0000000..527293f
--- /dev/null
+++ b/src/tui/state.rs
@@ -0,0 +1,301 @@
use crossterm::event::{KeyCode, KeyModifiers};
use git2::{Oid, Repository};
use ratatui::widgets::ListState;

use crate::state::{self, IssueState, IssueStatus, PatchState};

#[derive(Debug, PartialEq)]
pub(crate) enum Pane {
    ItemList,
    Detail,
}

#[derive(Debug, PartialEq)]
pub(crate) enum ViewMode {
    Details,
    CommitList,
    CommitDetail,
}

#[derive(Debug, PartialEq)]
pub(crate) enum KeyAction {
    Continue,
    Quit,
    Reload,
    OpenCommitBrowser,
}

#[derive(Debug, PartialEq, Clone, Copy)]
pub(crate) enum StatusFilter {
    Open,
    Closed,
    All,
}

impl StatusFilter {
    pub(crate) fn next(self) -> Self {
        match self {
            StatusFilter::Open => StatusFilter::All,
            StatusFilter::All => StatusFilter::Closed,
            StatusFilter::Closed => StatusFilter::Open,
        }
    }

    pub(crate) fn label(self) -> &'static str {
        match self {
            StatusFilter::Open => "open",
            StatusFilter::Closed => "closed",
            StatusFilter::All => "all",
        }
    }
}

#[derive(Debug, PartialEq)]
pub(crate) enum InputMode {
    Normal,
    Search,
    CreateTitle,
    CreateBody,
}

pub(crate) struct App {
    pub(crate) issues: Vec<IssueState>,
    pub(crate) patches: Vec<PatchState>,
    pub(crate) list_state: ListState,
    pub(crate) scroll: u16,
    pub(crate) pane: Pane,
    pub(crate) mode: ViewMode,
    pub(crate) status_filter: StatusFilter,
    pub(crate) search_query: String,
    pub(crate) input_mode: InputMode,
    pub(crate) input_buf: String,
    pub(crate) create_title: String,
    pub(crate) status_msg: Option<String>,
    pub(crate) event_history: Vec<(Oid, crate::event::Event)>,
    pub(crate) event_list_state: ListState,
}

impl App {
    pub(crate) fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self {
        let mut list_state = ListState::default();
        if !issues.is_empty() {
            list_state.select(Some(0));
        }
        Self {
            issues,
            patches,
            list_state,
            scroll: 0,
            pane: Pane::ItemList,
            mode: ViewMode::Details,
            status_filter: StatusFilter::Open,
            search_query: String::new(),
            input_mode: InputMode::Normal,
            input_buf: String::new(),
            create_title: String::new(),
            status_msg: None,
            event_history: Vec::new(),
            event_list_state: ListState::default(),
        }
    }

    pub(crate) fn matches_search(&self, title: &str) -> bool {
        if self.search_query.is_empty() {
            return true;
        }
        title
            .to_lowercase()
            .contains(&self.search_query.to_lowercase())
    }

    pub(crate) fn visible_issues(&self) -> Vec<&IssueState> {
        self.issues
            .iter()
            .filter(|i| match self.status_filter {
                StatusFilter::Open => i.status == IssueStatus::Open,
                StatusFilter::Closed => i.status == IssueStatus::Closed,
                StatusFilter::All => true,
            })
            .filter(|i| self.matches_search(&i.title))
            .collect()
    }

    pub(crate) fn visible_count(&self) -> usize {
        self.visible_issues().len()
    }

    pub(crate) fn move_selection(&mut self, delta: i32) {
        let len = self.visible_count();
        if len == 0 {
            return;
        }
        let current = self.list_state.selected().unwrap_or(0);
        let new = if delta > 0 {
            (current + delta as usize).min(len - 1)
        } else {
            current.saturating_sub((-delta) as usize)
        };
        self.list_state.select(Some(new));
        self.scroll = 0;
    }

    pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction {
        // Handle CommitDetail mode first
        if self.mode == ViewMode::CommitDetail {
            match code {
                KeyCode::Esc => {
                    self.mode = ViewMode::CommitList;
                    self.scroll = 0;
                    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.scroll = self.scroll.saturating_add(1);
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    self.scroll = self.scroll.saturating_sub(1);
                    return KeyAction::Continue;
                }
                KeyCode::PageDown => {
                    self.scroll = self.scroll.saturating_add(20);
                    return KeyAction::Continue;
                }
                KeyCode::PageUp => {
                    self.scroll = self.scroll.saturating_sub(20);
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Handle CommitList mode
        if self.mode == ViewMode::CommitList {
            match code {
                KeyCode::Esc => {
                    self.event_history.clear();
                    self.event_list_state = ListState::default();
                    self.mode = ViewMode::Details;
                    self.scroll = 0;
                    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 => {
                    let len = self.event_history.len();
                    if len > 0 {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = (current + 1).min(len - 1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Char('k') | KeyCode::Up => {
                    if !self.event_history.is_empty() {
                        let current = self.event_list_state.selected().unwrap_or(0);
                        let new = current.saturating_sub(1);
                        self.event_list_state.select(Some(new));
                    }
                    return KeyAction::Continue;
                }
                KeyCode::Enter => {
                    if self.event_list_state.selected().is_some() {
                        self.mode = ViewMode::CommitDetail;
                        self.scroll = 0;
                    }
                    return KeyAction::Continue;
                }
                _ => return KeyAction::Continue,
            }
        }

        // Normal Details mode handling
        match code {
            KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit,
            KeyCode::Char('c') => {
                // Open commit browser: only when in detail pane with an item selected
                if self.pane == Pane::Detail && self.list_state.selected().is_some() {
                    KeyAction::OpenCommitBrowser
                } else {
                    KeyAction::Continue
                }
            }
            KeyCode::Char('j') | KeyCode::Down => {
                if self.pane == Pane::ItemList {
                    self.move_selection(1);
                } else {
                    self.scroll = self.scroll.saturating_add(1);
                }
                KeyAction::Continue
            }
            KeyCode::Char('k') | KeyCode::Up => {
                if self.pane == Pane::ItemList {
                    self.move_selection(-1);
                } else {
                    self.scroll = self.scroll.saturating_sub(1);
                }
                KeyAction::Continue
            }
            KeyCode::PageDown => {
                self.scroll = self.scroll.saturating_add(20);
                KeyAction::Continue
            }
            KeyCode::PageUp => {
                self.scroll = self.scroll.saturating_sub(20);
                KeyAction::Continue
            }
            KeyCode::Tab | KeyCode::Enter => {
                self.pane = match self.pane {
                    Pane::ItemList => Pane::Detail,
                    Pane::Detail => Pane::ItemList,
                };
                KeyAction::Continue
            }
            KeyCode::Char('a') => {
                self.status_filter = self.status_filter.next();
                let count = self.visible_count();
                self.list_state
                    .select(if count > 0 { Some(0) } else { None });
                KeyAction::Continue
            }
            KeyCode::Char('r') => KeyAction::Reload,
            _ => KeyAction::Continue,
        }
    }

    pub(crate) fn selected_item_id(&self) -> Option<String> {
        let idx = self.list_state.selected()?;
        let visible = self.visible_issues();
        visible.get(idx).map(|i| i.id.clone())
    }

    pub(crate) fn selected_ref_name(&self) -> Option<String> {
        let id = self.selected_item_id()?;
        Some(format!("refs/collab/issues/{}", id))
    }

    pub(crate) fn reload(&mut self, repo: &Repository) {
        if let Ok(issues) = state::list_issues(repo) {
            self.issues = issues;
        }
        if let Ok(patches) = state::list_patches(repo) {
            self.patches = patches;
        }
        let visible_len = self.visible_count();
        if let Some(sel) = self.list_state.selected() {
            if sel >= visible_len {
                self.list_state.select(if visible_len > 0 {
                    Some(visible_len - 1)
                } else {
                    None
                });
            }
        }
    }
}
diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs
new file mode 100644
index 0000000..e786584
--- /dev/null
+++ b/src/tui/widgets.rs
@@ -0,0 +1,463 @@
use git2::Oid;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};

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

use super::state::{App, InputMode, Pane, StatusFilter, ViewMode};

pub(crate) fn action_type_label(action: &Action) -> &str {
    match action {
        Action::IssueOpen { .. } => "Issue Open",
        Action::IssueComment { .. } => "Issue Comment",
        Action::IssueClose { .. } => "Issue Close",
        Action::IssueReopen => "Issue Reopen",
        Action::PatchCreate { .. } => "Patch Create",
        Action::PatchRevise { .. } => "Patch Revise",
        Action::PatchReview { .. } => "Patch Review",
        Action::PatchComment { .. } => "Patch Comment",
        Action::PatchInlineComment { .. } => "Inline Comment",
        Action::PatchClose { .. } => "Patch Close",
        Action::PatchMerge => "Patch Merge",
        Action::Merge => "Merge",
        Action::IssueEdit { .. } => "Issue Edit",
        Action::IssueLabel { .. } => "Issue Label",
        Action::IssueUnlabel { .. } => "Issue Unlabel",
        Action::IssueAssign { .. } => "Issue Assign",
        Action::IssueUnassign { .. } => "Issue Unassign",
    }
}

pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String {
    let short_oid = &oid.to_string()[..7];
    let action_label = action_type_label(&event.action);

    let mut detail = format!(
        "Commit:  {}\nAuthor:  {} <{}>\nDate:    {}\nType:    {}\n",
        short_oid, event.author.name, event.author.email, event.timestamp, action_label,
    );

    // Action-specific payload
    match &event.action {
        Action::IssueOpen { title, body } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueComment { body } | Action::PatchComment { body } => {
            detail.push_str(&format!("\n{}\n", body));
        }
        Action::IssueClose { reason } | Action::PatchClose { reason } => {
            if let Some(r) = reason {
                detail.push_str(&format!("\nReason: {}\n", r));
            }
        }
        Action::PatchCreate {
            title,
            body,
            base_ref,
            branch,
            ..
        } => {
            detail.push_str(&format!("\nTitle:  {}\n", title));
            detail.push_str(&format!("Base:   {}\n", base_ref));
            detail.push_str(&format!("Branch: {}\n", branch));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchRevise { body } => {
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\n{}\n", b));
                }
            }
        }
        Action::PatchReview { verdict, body } => {
            detail.push_str(&format!("\nVerdict: {:?}\n", verdict));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::PatchInlineComment { file, line, body } => {
            detail.push_str(&format!("\nFile: {}:{}\n", file, line));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
            }
        }
        Action::IssueEdit { title, body } => {
            if let Some(t) = title {
                detail.push_str(&format!("\nNew Title: {}\n", t));
            }
            if let Some(b) = body {
                if !b.is_empty() {
                    detail.push_str(&format!("\nNew Body: {}\n", b));
                }
            }
        }
        Action::IssueLabel { label } => {
            detail.push_str(&format!("\nLabel: {}\n", label));
        }
        Action::IssueUnlabel { label } => {
            detail.push_str(&format!("\nRemoved Label: {}\n", label));
        }
        Action::IssueAssign { assignee } => {
            detail.push_str(&format!("\nAssignee: {}\n", assignee));
        }
        Action::IssueUnassign { assignee } => {
            detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee));
        }
        Action::IssueReopen | Action::PatchMerge | Action::Merge => {}
    }

    detail
}

pub(crate) fn ui(frame: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
        ])
        .split(frame.area());

    let main_area = chunks[0];
    let footer_area = chunks[1];

    let panes = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
        .split(main_area);

    render_list(frame, app, panes[0]);
    render_detail(frame, app, panes[1]);
    render_footer(frame, app, footer_area);
}

fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::ItemList {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    let visible = app.visible_issues();
    let items: Vec<ListItem> = visible
        .iter()
        .map(|i| {
            let status = match i.status {
                IssueStatus::Open => "open",
                IssueStatus::Closed => "closed",
            };
            let style = match i.status {
                IssueStatus::Open => Style::default().fg(Color::Green),
                IssueStatus::Closed => Style::default().fg(Color::Red),
            };
            ListItem::new(format!("{:.8}  {:6}  {}", i.id, status, i.title)).style(style)
        })
        .collect();

    let title = format!("Issues ({})", app.status_filter.label());

    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(title)
                .border_style(border_style),
        )
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol("> ");

    frame.render_stateful_widget(list, area, &mut app.list_state);
}

fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) {
    let border_style = if app.pane == Pane::Detail {
        Style::default().fg(Color::Yellow)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    // Handle commit browser modes
    if app.mode == ViewMode::CommitList {
        let items: Vec<ListItem> = app
            .event_history
            .iter()
            .map(|(_oid, evt)| {
                let label = action_type_label(&evt.action);
                ListItem::new(format!(
                    "{} | {} | {}",
                    label, evt.author.name, evt.timestamp
                ))
            })
            .collect();

        let list = List::new(items)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .title("Event History")
                    .border_style(border_style),
            )
            .highlight_style(
                Style::default()
                    .bg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, area, &mut app.event_list_state);
        return;
    }

    if app.mode == ViewMode::CommitDetail {
        let content = if let Some(idx) = app.event_list_state.selected() {
            if let Some((oid, evt)) = app.event_history.get(idx) {
                format_event_detail(oid, evt)
            } else {
                "No event selected.".to_string()
            }
        } else {
            "No event selected.".to_string()
        };

        let block = Block::default()
            .borders(Borders::ALL)
            .title("Event Detail")
            .border_style(border_style);

        let para = Paragraph::new(content)
            .block(block)
            .wrap(Wrap { trim: false })
            .scroll((app.scroll, 0));

        frame.render_widget(para, area);
        return;
    }

    let visible = app.visible_issues();
    let selected_idx = app.list_state.selected().unwrap_or(0);
    let content: Text = match visible.get(selected_idx) {
        Some(issue) => build_issue_detail(issue, &app.patches),
        None => Text::raw("No matches for current filter."),
    };

    let block = Block::default()
        .borders(Borders::ALL)
        .title("Issue Details")
        .border_style(border_style);

    let para = Paragraph::new(content)
        .block(block)
        .wrap(Wrap { trim: false })
        .scroll((app.scroll, 0));

    frame.render_widget(para, area);
}

fn build_issue_detail(issue: &IssueState, patches: &[PatchState]) -> Text<'static> {
    let status = match issue.status {
        IssueStatus::Open => "open",
        IssueStatus::Closed => "closed",
    };

    let mut lines: Vec<Line> = vec![
        Line::from(vec![
            Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("{:.8}", issue.id),
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" [{}]", status)),
        ]),
        Line::from(vec![
            Span::styled("Title:   ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.title.clone()),
        ]),
        Line::from(vec![
            Span::styled("Author:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(format!("{} <{}>", issue.author.name, issue.author.email)),
        ]),
        Line::from(vec![
            Span::styled("Created: ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.created_at.clone()),
        ]),
    ];

    if !issue.labels.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Labels:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.labels.join(", ")),
        ]));
    }

    if !issue.assignees.is_empty() {
        lines.push(Line::from(vec![
            Span::styled("Assign:  ", Style::default().fg(Color::DarkGray)),
            Span::raw(issue.assignees.join(", ")),
        ]));
    }

    if let Some(ref reason) = issue.close_reason {
        lines.push(Line::from(vec![
            Span::styled("Closed:  ", Style::default().fg(Color::Red)),
            Span::raw(reason.clone()),
        ]));
    }

    if let Some(ref oid) = issue.closed_by {
        lines.push(Line::from(vec![
            Span::styled("Commit:  ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                format!("{:.8}", oid),
                Style::default().fg(Color::Cyan),
            ),
        ]));
    }

    // Show patches that reference this issue via --fixes
    let fixing_patches: Vec<&PatchState> = patches
        .iter()
        .filter(|p| p.fixes.as_deref() == Some(&issue.id))
        .collect();
    if !fixing_patches.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Linked Patches ---",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ));
        for p in &fixing_patches {
            let status = match p.status {
                PatchStatus::Open => ("open", Color::Green),
                PatchStatus::Closed => ("closed", Color::Red),
                PatchStatus::Merged => ("merged", Color::Cyan),
            };
            lines.push(Line::from(vec![
                Span::styled(
                    format!("{:.8}", p.id),
                    Style::default().fg(Color::Yellow),
                ),
                Span::raw("  "),
                Span::styled(status.0, Style::default().fg(status.1)),
                Span::raw(format!("  {}", p.title)),
            ]));
        }
    }

    if !issue.body.is_empty() {
        lines.push(Line::raw(""));
        for l in issue.body.lines() {
            lines.push(Line::raw(l.to_string()));
        }
    }

    if !issue.comments.is_empty() {
        lines.push(Line::raw(""));
        lines.push(Line::styled(
            "--- Comments ---",
            Style::default()
                .fg(Color::Blue)
                .add_modifier(Modifier::BOLD),
        ));
        for c in &issue.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)));
            }
        }
    }

    Text::from(lines)
}

fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
    match app.input_mode {
        InputMode::Search => {
            let max_query_len = (area.width as usize).saturating_sub(12);
            let display_query = if app.search_query.len() > max_query_len {
                &app.search_query[app.search_query.len() - max_query_len..]
            } else {
                &app.search_query
            };
            let text = format!(" Search: {}_", display_query);
            let style = Style::default().bg(Color::Blue).fg(Color::White);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateTitle => {
            let max_len = (area.width as usize).saturating_sub(22);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Title: {}_", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::CreateBody => {
            let max_len = (area.width as usize).saturating_sub(21);
            let display = if app.input_buf.len() > max_len {
                &app.input_buf[app.input_buf.len() - max_len..]
            } else {
                &app.input_buf
            };
            let text = format!(" New issue - Body: {}_  (Esc: skip)", display);
            let style = Style::default().bg(Color::Green).fg(Color::Black);
            let para = Paragraph::new(text).style(style);
            frame.render_widget(para, area);
            return;
        }
        InputMode::Normal => {}
    }

    // Show status message if present
    if let Some(ref msg) = app.status_msg {
        let para =
            Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black));
        frame.render_widget(para, area);
        return;
    }

    let mode_hint = match app.mode {
        ViewMode::CommitList => "  Esc:back",
        ViewMode::CommitDetail => "  Esc:back  j/k:scroll",
        ViewMode::Details => "  c:events",
    };
    let filter_hint = match app.status_filter {
        StatusFilter::Open => "a:show all",
        StatusFilter::All => "a:closed",
        StatusFilter::Closed => "a:open only",
    };
    let text = format!(
        " j/k:navigate  Tab:pane  {}{}  /:search  n:new issue  o:checkout  r:refresh  q:quit",
        filter_hint, mode_hint
    );
    let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White));
    frame.render_widget(para, area);
}