travelagent 1.11.1

Agent-first TUI code review tool
//! Event loop for the PR list picker.
//!
//! Stands up an alternate-screen TUI, fetches the list of open PRs from the
//! configured forge, lets the user pick one with j/k/Enter, and returns the
//! selected PR number (or `None` if they cancelled).

use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use crossterm::{
    event::{self, Event, KeyEventKind},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
use tokio::runtime::Handle;

use travelagent_core::config::ConfigLoadOutcome;
use travelagent_core::forge::{ForgeRead, ForgeType, PrListFilter, PrListItem};

use crate::forge_detect;
use crate::theme::Theme;
use crate::ui::pr_list::{self, PrListEvent, PrListState};

/// Outcome of running the PR list picker.
pub enum PickOutcome {
    /// User picked a PR; contains the `PrListItem` so the caller can assemble the URL.
    Picked(PrListItem),
    /// User quit without picking.
    Cancelled,
}

/// Fetch the list of open PRs for the repository the current directory lives
/// in, show the picker, and return the chosen PR's number. Cancellation
/// returns `Ok(None)`.
pub fn run_picker(
    theme: &Theme,
    config_outcome: &ConfigLoadOutcome,
    runtime_handle: &Handle,
) -> anyhow::Result<(PickOutcome, String, String, Option<String>, ForgeType)> {
    let forge_hosts = config_outcome
        .config
        .as_ref()
        .and_then(|cfg| cfg.forge_hosts.as_ref());

    // Detect which repo we're in.
    let (forge_type, owner, repo, custom_host) =
        forge_detect::detect_forge_from_remote(forge_hosts)?;

    // Build the forge client. Narrowed to `ForgeRead` since this picker
    // only calls `list_prs` and (implicitly via list_prs) `forge_type`;
    // it never mutates PRs, so the broader `ForgeBackend` bound would be
    // gratuitous. Wrapped in `Arc` so we can share it between the
    // synchronous `list_prs` call and the background `current_user`
    // task (both must reach the event loop without owning the Box).
    let forge: Arc<dyn ForgeRead> = match forge_type {
        ForgeType::GitHub => {
            let f = if let Some(host) = custom_host.as_deref() {
                let base_url = format!("https://{host}/api/v3");
                travelagent_forge_github::GitHubForge::with_base_url(&base_url)?
            } else {
                travelagent_forge_github::GitHubForge::new()?
            };
            Arc::new(f)
        }
        ForgeType::GitLab => {
            let f = if let Some(host) = custom_host.as_deref() {
                let base_url = format!("https://{host}");
                travelagent_forge_gitlab::GitLabForge::with_base_url(&base_url)?
            } else {
                travelagent_forge_gitlab::GitLabForge::new()?
            };
            Arc::new(f)
        }
    };

    // Fetch rows using the process-wide shared tokio runtime.
    let filter = PrListFilter {
        state: Some(travelagent_core::forge::PrState::Open),
        ..Default::default()
    };
    eprintln!("Fetching open PRs for {owner}/{repo}...");
    let rows = runtime_handle.block_on(forge.list_prs(&owner, &repo, &filter))?;

    // Resolve the signed-in user in the background so a slow auth endpoint
    // doesn't wedge picker startup. The event loop reads the shared slot
    // each iteration and promotes the resolved login into `state.me_login`
    // once it lands — until then, `@me` is kept as a literal (won't match
    // anything). Claude flagged the synchronous block_on in the post-F
    // crew review (2026-05-02).
    let me_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
    let me_slot_clone = Arc::clone(&me_slot);
    let forge_for_me = Arc::clone(&forge);
    runtime_handle.spawn(async move {
        if let Ok(user) = forge_for_me.current_user().await
            && let Ok(mut slot) = me_slot_clone.lock()
        {
            *slot = Some(user.login);
        }
    });

    let outcome = run_loop(theme, &rows, &owner, &repo, me_slot)?;
    Ok((outcome, owner, repo, custom_host, forge_type))
}

fn run_loop(
    theme: &Theme,
    rows: &[PrListItem],
    owner: &str,
    repo: &str,
    me_slot: Arc<Mutex<Option<String>>>,
) -> anyhow::Result<PickOutcome> {
    // Terminal setup — mirrors main.rs tear-up, but plain (no bracketed-paste
    // or keyboard-enhancement since the picker is tiny and short-lived).
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut state = PrListState::new();
    // Seed from the slot in case the background task already resolved
    // before we got here (rare but possible if current_user() is cached).
    if let Ok(slot) = me_slot.lock() {
        state.me_login = slot.clone();
    }
    let header = format!("Open PRs in {owner}/{repo}");

    let outcome = event_loop(&mut terminal, rows, &mut state, theme, &header, &me_slot);

    // Tear down — best-effort so a failed restore doesn't mask the picker's result.
    let _ = disable_raw_mode();
    let _ = execute!(io::stdout(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();
    let _ = io::stdout().flush();

    outcome
}

fn event_loop<B>(
    terminal: &mut Terminal<B>,
    rows: &[PrListItem],
    state: &mut PrListState,
    theme: &Theme,
    header: &str,
    me_slot: &Arc<Mutex<Option<String>>>,
) -> anyhow::Result<PickOutcome>
where
    B: ratatui::backend::Backend,
    <B as ratatui::backend::Backend>::Error: Send + Sync + 'static,
{
    loop {
        // Promote a freshly-resolved signed-in user into state so `@me`
        // in a filter prompt starts working as soon as the background
        // `current_user()` task lands. Cheap — a mutex lock per iteration
        // gated by the 250ms event-poll, and a short-circuit once set.
        if state.me_login.is_none()
            && let Ok(slot) = me_slot.lock()
            && let Some(login) = slot.as_ref()
        {
            state.me_login = Some(login.clone());
        }

        let mut area = Rect::default();
        terminal.draw(|frame| {
            area = frame.area();
            pr_list::render(frame, area, rows, state, theme, header);
        })?;
        let viewport = (area.height as usize).saturating_sub(2);

        // The renderer applies client-side filters (`state.filters`) internally,
        // so cursor bounds and selection must operate on the *filtered* row
        // count — not `rows.len()`. Previously we passed `rows.len()` to
        // `handle_key` and indexed `rows[state.cursor]` on Select, which opened
        // the wrong PR after any filter chord (flagged CRITICAL by gpt-5.2).
        let visible = pr_list::apply_filters(rows, &state.filters);

        if event::poll(Duration::from_millis(250))?
            && let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            match pr_list::handle_key(key, state, visible.len(), viewport) {
                PrListEvent::Nothing | PrListEvent::Redraw => continue,
                PrListEvent::Quit => return Ok(PickOutcome::Cancelled),
                PrListEvent::Select => {
                    if let Some(item) = visible.get(state.cursor) {
                        return Ok(PickOutcome::Picked(item.clone()));
                    }
                    // Cursor past the filtered end (empty view, or stale
                    // cursor). Treat as no-op rather than opening a wrong row.
                    continue;
                }
            }
        }
    }
}