1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
//! 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;
}
}
}
}
}