1use tui::{Component, Event, Frame, Line, SelectItem, SelectList, SelectListMessage, ViewContext};
2
3pub struct ProviderLoginOverlay {
4 list: SelectList<ProviderLoginEntry>,
5}
6
7pub struct ProviderLoginEntry {
8 pub method_id: String,
9 pub name: String,
10 pub status: ProviderLoginStatus,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ProviderLoginStatus {
15 NeedsLogin,
16 Authenticating,
17 LoggedIn,
18}
19
20pub enum ProviderLoginMessage {
21 Close,
22 Authenticate(String),
23}
24
25impl SelectItem for ProviderLoginEntry {
26 fn render_item(&self, selected: bool, context: &ViewContext) -> Line {
27 let (indicator, detail) = match &self.status {
28 ProviderLoginStatus::NeedsLogin => ("⚡", "needs login"),
29 ProviderLoginStatus::Authenticating => ("⏳", "authenticating..."),
30 ProviderLoginStatus::LoggedIn => ("✓", "logged in"),
31 };
32 let text = format!("{} {indicator} {detail}", self.name);
33 if self.status == ProviderLoginStatus::LoggedIn {
34 if selected {
35 Line::with_style(
36 text,
37 context
38 .theme
39 .selected_row_style_with_fg(context.theme.success()),
40 )
41 } else {
42 Line::styled(text, context.theme.success())
43 }
44 } else if selected {
45 Line::with_style(
46 text,
47 context
48 .theme
49 .selected_row_style_with_fg(context.theme.warning()),
50 )
51 } else {
52 Line::styled(text, context.theme.warning())
53 }
54 }
55}
56
57impl Component for ProviderLoginOverlay {
58 type Message = ProviderLoginMessage;
59
60 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
61 let outcome = self.list.on_event(event).await;
62 match outcome.as_deref() {
63 Some([SelectListMessage::Close]) => Some(vec![ProviderLoginMessage::Close]),
64 Some([SelectListMessage::Select(_)]) => {
65 if let Some(entry) = self.list.selected_item()
66 && entry.status != ProviderLoginStatus::Authenticating
67 {
68 return Some(vec![ProviderLoginMessage::Authenticate(
69 entry.method_id.clone(),
70 )]);
71 }
72 Some(vec![])
73 }
74 _ => outcome.map(|_| vec![]),
75 }
76 }
77
78 fn render(&mut self, context: &ViewContext) -> Frame {
79 self.list.render(context)
80 }
81}
82
83pub fn provider_login_summary(entries: &[ProviderLoginEntry]) -> String {
84 if entries.is_empty() {
85 return "all logged in".to_string();
86 }
87 let needs_login = entries
88 .iter()
89 .filter(|e| e.status == ProviderLoginStatus::NeedsLogin)
90 .count();
91 let authenticating = entries
92 .iter()
93 .filter(|e| e.status == ProviderLoginStatus::Authenticating)
94 .count();
95 let logged_in = entries
96 .iter()
97 .filter(|e| e.status == ProviderLoginStatus::LoggedIn)
98 .count();
99 let parts: Vec<String> = [
100 (needs_login, "needs login"),
101 (authenticating, "authenticating"),
102 (logged_in, "logged in"),
103 ]
104 .iter()
105 .filter(|(count, _)| *count > 0)
106 .map(|(count, label)| format!("{count} {label}"))
107 .collect();
108 if parts.is_empty() {
109 "all logged in".to_string()
110 } else {
111 parts.join(", ")
112 }
113}
114
115impl ProviderLoginOverlay {
116 pub fn new(entries: Vec<ProviderLoginEntry>) -> Self {
117 Self {
118 list: SelectList::new(entries, "no providers need login"),
119 }
120 }
121
122 pub fn replace_entries(&mut self, entries: Vec<ProviderLoginEntry>) {
123 let selected_method_id = self
124 .list
125 .selected_item()
126 .map(|entry| entry.method_id.clone());
127
128 self.list.set_items(entries);
129
130 if let Some(selected_method_id) = selected_method_id
131 && let Some(index) = self
132 .list
133 .items()
134 .iter()
135 .position(|entry| entry.method_id == selected_method_id)
136 {
137 self.list.set_selected(index);
138 }
139 }
140
141 #[cfg(test)]
142 pub fn entries(&self) -> &[ProviderLoginEntry] {
143 self.list.items()
144 }
145
146 pub fn reset_to_needs_login(&mut self, method_id: &str) {
147 if let Some(entry) = self
148 .list
149 .items_mut()
150 .iter_mut()
151 .find(|e| e.method_id == method_id)
152 {
153 entry.status = ProviderLoginStatus::NeedsLogin;
154 }
155 }
156
157 pub fn set_logged_in(&mut self, method_id: &str) {
158 if let Some(entry) = self
159 .list
160 .items_mut()
161 .iter_mut()
162 .find(|e| e.method_id == method_id)
163 {
164 entry.status = ProviderLoginStatus::LoggedIn;
165 }
166 }
167
168 pub fn set_authenticating(&mut self, method_id: &str) {
169 if let Some(entry) = self
170 .list
171 .items_mut()
172 .iter_mut()
173 .find(|e| e.method_id == method_id)
174 {
175 entry.status = ProviderLoginStatus::Authenticating;
176 }
177 }
178
179 #[cfg(test)]
180 pub fn remove_entry(&mut self, method_id: &str) {
181 self.list.retain(|e| e.method_id != method_id);
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use tui::{KeyCode, KeyEvent, KeyModifiers};
189
190 fn sample_entries() -> Vec<ProviderLoginEntry> {
191 vec![ProviderLoginEntry {
192 method_id: "codex".to_string(),
193 name: "Codex".to_string(),
194 status: ProviderLoginStatus::NeedsLogin,
195 }]
196 }
197
198 #[test]
199 fn renders_entries_with_status_indicators() {
200 let mut overlay = ProviderLoginOverlay::new(sample_entries());
201 let ctx = ViewContext::new((80, 24));
202 let frame = overlay.render(&ctx);
203
204 assert_eq!(frame.lines().len(), 1);
205 let text = frame.lines()[0].plain_text();
206 assert!(text.contains("Codex"), "should contain provider name");
207 assert!(text.contains("⚡"), "needs login should show bolt");
208 }
209
210 #[tokio::test]
211 async fn enter_on_needs_login_emits_authenticate() {
212 let mut overlay = ProviderLoginOverlay::new(sample_entries());
213 let outcome = overlay
214 .on_event(&Event::Key(KeyEvent::new(
215 KeyCode::Enter,
216 KeyModifiers::NONE,
217 )))
218 .await;
219 let messages = outcome.unwrap();
220 match messages.as_slice() {
221 [ProviderLoginMessage::Authenticate(id)] => assert_eq!(id, "codex"),
222 _ => panic!("Expected Authenticate message"),
223 }
224 }
225
226 #[tokio::test]
227 async fn enter_on_authenticating_is_noop() {
228 let mut entries = sample_entries();
229 entries[0].status = ProviderLoginStatus::Authenticating;
230 let mut overlay = ProviderLoginOverlay::new(entries);
231 let outcome = overlay
232 .on_event(&Event::Key(KeyEvent::new(
233 KeyCode::Enter,
234 KeyModifiers::NONE,
235 )))
236 .await;
237 assert!(outcome.unwrap().is_empty());
238 }
239
240 #[tokio::test]
241 async fn esc_closes_overlay() {
242 let mut overlay = ProviderLoginOverlay::new(sample_entries());
243 let outcome = overlay
244 .on_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)))
245 .await;
246 let messages = outcome.unwrap();
247 assert!(matches!(messages.as_slice(), [ProviderLoginMessage::Close]));
248 }
249
250 #[test]
251 fn empty_entries_shows_placeholder() {
252 let mut overlay = ProviderLoginOverlay::new(vec![]);
253 let ctx = ViewContext::new((80, 24));
254 let frame = overlay.render(&ctx);
255 assert!(
256 frame.lines()[0]
257 .plain_text()
258 .contains("no providers need login")
259 );
260 }
261
262 #[test]
263 fn set_authenticating_updates_status() {
264 let mut overlay = ProviderLoginOverlay::new(sample_entries());
265 overlay.set_authenticating("codex");
266 assert_eq!(
267 overlay.entries()[0].status,
268 ProviderLoginStatus::Authenticating
269 );
270 }
271
272 #[test]
273 fn remove_entry_clamps_selection() {
274 let entries = vec![
275 ProviderLoginEntry {
276 method_id: "a".to_string(),
277 name: "A".to_string(),
278 status: ProviderLoginStatus::NeedsLogin,
279 },
280 ProviderLoginEntry {
281 method_id: "b".to_string(),
282 name: "B".to_string(),
283 status: ProviderLoginStatus::NeedsLogin,
284 },
285 ];
286 let mut overlay = ProviderLoginOverlay::new(entries);
287 overlay.list.set_selected(1);
288 overlay.remove_entry("b");
289 assert_eq!(overlay.entries().len(), 1);
290 assert_eq!(overlay.list.selected_index(), 0);
291 }
292
293 #[test]
294 fn provider_login_summary_formats_correctly() {
295 assert_eq!(provider_login_summary(&[]), "all logged in");
296 assert_eq!(provider_login_summary(&sample_entries()), "1 needs login");
297 }
298
299 #[test]
300 fn provider_login_summary_shows_logged_in() {
301 let entries = vec![ProviderLoginEntry {
302 method_id: "codex".to_string(),
303 name: "Codex".to_string(),
304 status: ProviderLoginStatus::LoggedIn,
305 }];
306 assert_eq!(provider_login_summary(&entries), "1 logged in");
307 }
308
309 #[test]
310 fn provider_login_summary_mixed_statuses() {
311 let entries = vec![
312 ProviderLoginEntry {
313 method_id: "a".to_string(),
314 name: "A".to_string(),
315 status: ProviderLoginStatus::NeedsLogin,
316 },
317 ProviderLoginEntry {
318 method_id: "b".to_string(),
319 name: "B".to_string(),
320 status: ProviderLoginStatus::LoggedIn,
321 },
322 ];
323 assert_eq!(
324 provider_login_summary(&entries),
325 "1 needs login, 1 logged in"
326 );
327 }
328
329 #[test]
330 fn set_logged_in_updates_status() {
331 let mut overlay = ProviderLoginOverlay::new(sample_entries());
332 overlay.set_logged_in("codex");
333 assert_eq!(overlay.entries()[0].status, ProviderLoginStatus::LoggedIn);
334 }
335
336 #[test]
337 fn renders_logged_in_with_check_mark() {
338 let entries = vec![ProviderLoginEntry {
339 method_id: "codex".to_string(),
340 name: "Codex".to_string(),
341 status: ProviderLoginStatus::LoggedIn,
342 }];
343 let mut overlay = ProviderLoginOverlay::new(entries);
344 let ctx = ViewContext::new((80, 24));
345 let frame = overlay.render(&ctx);
346 let text = frame.lines()[0].plain_text();
347 assert!(text.contains("✓"), "logged in should show check mark");
348 assert!(text.contains("logged in"), "should show 'logged in' text");
349 }
350
351 #[tokio::test]
352 async fn enter_on_logged_in_emits_authenticate_for_reauth() {
353 let entries = vec![ProviderLoginEntry {
354 method_id: "codex".to_string(),
355 name: "Codex".to_string(),
356 status: ProviderLoginStatus::LoggedIn,
357 }];
358 let mut overlay = ProviderLoginOverlay::new(entries);
359 let outcome = overlay
360 .on_event(&Event::Key(KeyEvent::new(
361 KeyCode::Enter,
362 KeyModifiers::NONE,
363 )))
364 .await;
365 let messages = outcome.unwrap();
366 match messages.as_slice() {
367 [ProviderLoginMessage::Authenticate(id)] => assert_eq!(id, "codex"),
368 _ => panic!("Expected Authenticate message for re-auth"),
369 }
370 }
371}