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(text, context.theme.selected_row_style_with_fg(context.theme.success()))
36 } else {
37 Line::styled(text, context.theme.success())
38 }
39 } else if selected {
40 Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.warning()))
41 } else {
42 Line::styled(text, context.theme.warning())
43 }
44 }
45}
46
47impl Component for ProviderLoginOverlay {
48 type Message = ProviderLoginMessage;
49
50 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
51 let outcome = self.list.on_event(event).await;
52 match outcome.as_deref() {
53 Some([SelectListMessage::Close]) => Some(vec![ProviderLoginMessage::Close]),
54 Some([SelectListMessage::Select(_)]) => {
55 if let Some(entry) = self.list.selected_item()
56 && entry.status != ProviderLoginStatus::Authenticating
57 {
58 return Some(vec![ProviderLoginMessage::Authenticate(entry.method_id.clone())]);
59 }
60 Some(vec![])
61 }
62 _ => outcome.map(|_| vec![]),
63 }
64 }
65
66 fn render(&mut self, context: &ViewContext) -> Frame {
67 self.list.render(context)
68 }
69}
70
71pub fn provider_login_summary(entries: &[ProviderLoginEntry]) -> String {
72 if entries.is_empty() {
73 return "all logged in".to_string();
74 }
75 let needs_login = entries.iter().filter(|e| e.status == ProviderLoginStatus::NeedsLogin).count();
76 let authenticating = entries.iter().filter(|e| e.status == ProviderLoginStatus::Authenticating).count();
77 let logged_in = entries.iter().filter(|e| e.status == ProviderLoginStatus::LoggedIn).count();
78 let parts: Vec<String> =
79 [(needs_login, "needs login"), (authenticating, "authenticating"), (logged_in, "logged in")]
80 .iter()
81 .filter(|(count, _)| *count > 0)
82 .map(|(count, label)| format!("{count} {label}"))
83 .collect();
84 if parts.is_empty() { "all logged in".to_string() } else { parts.join(", ") }
85}
86
87impl ProviderLoginOverlay {
88 pub fn new(entries: Vec<ProviderLoginEntry>) -> Self {
89 Self { list: SelectList::new(entries, "no providers need login") }
90 }
91
92 pub fn replace_entries(&mut self, entries: Vec<ProviderLoginEntry>) {
93 let selected_method_id = self.list.selected_item().map(|entry| entry.method_id.clone());
94
95 self.list.set_items(entries);
96
97 if let Some(selected_method_id) = selected_method_id
98 && let Some(index) = self.list.items().iter().position(|entry| entry.method_id == selected_method_id)
99 {
100 self.list.set_selected(index);
101 }
102 }
103
104 #[cfg(test)]
105 pub fn entries(&self) -> &[ProviderLoginEntry] {
106 self.list.items()
107 }
108
109 pub fn reset_to_needs_login(&mut self, method_id: &str) {
110 if let Some(entry) = self.list.items_mut().iter_mut().find(|e| e.method_id == method_id) {
111 entry.status = ProviderLoginStatus::NeedsLogin;
112 }
113 }
114
115 pub fn set_logged_in(&mut self, method_id: &str) {
116 if let Some(entry) = self.list.items_mut().iter_mut().find(|e| e.method_id == method_id) {
117 entry.status = ProviderLoginStatus::LoggedIn;
118 }
119 }
120
121 pub fn set_authenticating(&mut self, method_id: &str) {
122 if let Some(entry) = self.list.items_mut().iter_mut().find(|e| e.method_id == method_id) {
123 entry.status = ProviderLoginStatus::Authenticating;
124 }
125 }
126
127 #[cfg(test)]
128 pub fn remove_entry(&mut self, method_id: &str) {
129 self.list.retain(|e| e.method_id != method_id);
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use tui::{KeyCode, KeyEvent, KeyModifiers};
137
138 fn sample_entries() -> Vec<ProviderLoginEntry> {
139 vec![ProviderLoginEntry {
140 method_id: "codex".to_string(),
141 name: "Codex".to_string(),
142 status: ProviderLoginStatus::NeedsLogin,
143 }]
144 }
145
146 #[test]
147 fn renders_entries_with_status_indicators() {
148 let mut overlay = ProviderLoginOverlay::new(sample_entries());
149 let ctx = ViewContext::new((80, 24));
150 let frame = overlay.render(&ctx);
151
152 assert_eq!(frame.lines().len(), 1);
153 let text = frame.lines()[0].plain_text();
154 assert!(text.contains("Codex"), "should contain provider name");
155 assert!(text.contains("⚡"), "needs login should show bolt");
156 }
157
158 #[tokio::test]
159 async fn enter_on_needs_login_emits_authenticate() {
160 let mut overlay = ProviderLoginOverlay::new(sample_entries());
161 let outcome = overlay.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await;
162 let messages = outcome.unwrap();
163 match messages.as_slice() {
164 [ProviderLoginMessage::Authenticate(id)] => assert_eq!(id, "codex"),
165 _ => panic!("Expected Authenticate message"),
166 }
167 }
168
169 #[tokio::test]
170 async fn enter_on_authenticating_is_noop() {
171 let mut entries = sample_entries();
172 entries[0].status = ProviderLoginStatus::Authenticating;
173 let mut overlay = ProviderLoginOverlay::new(entries);
174 let outcome = overlay.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await;
175 assert!(outcome.unwrap().is_empty());
176 }
177
178 #[tokio::test]
179 async fn esc_closes_overlay() {
180 let mut overlay = ProviderLoginOverlay::new(sample_entries());
181 let outcome = overlay.on_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))).await;
182 let messages = outcome.unwrap();
183 assert!(matches!(messages.as_slice(), [ProviderLoginMessage::Close]));
184 }
185
186 #[test]
187 fn empty_entries_shows_placeholder() {
188 let mut overlay = ProviderLoginOverlay::new(vec![]);
189 let ctx = ViewContext::new((80, 24));
190 let frame = overlay.render(&ctx);
191 assert!(frame.lines()[0].plain_text().contains("no providers need login"));
192 }
193
194 #[test]
195 fn set_authenticating_updates_status() {
196 let mut overlay = ProviderLoginOverlay::new(sample_entries());
197 overlay.set_authenticating("codex");
198 assert_eq!(overlay.entries()[0].status, ProviderLoginStatus::Authenticating);
199 }
200
201 #[test]
202 fn remove_entry_clamps_selection() {
203 let entries = vec![
204 ProviderLoginEntry {
205 method_id: "a".to_string(),
206 name: "A".to_string(),
207 status: ProviderLoginStatus::NeedsLogin,
208 },
209 ProviderLoginEntry {
210 method_id: "b".to_string(),
211 name: "B".to_string(),
212 status: ProviderLoginStatus::NeedsLogin,
213 },
214 ];
215 let mut overlay = ProviderLoginOverlay::new(entries);
216 overlay.list.set_selected(1);
217 overlay.remove_entry("b");
218 assert_eq!(overlay.entries().len(), 1);
219 assert_eq!(overlay.list.selected_index(), 0);
220 }
221
222 #[test]
223 fn provider_login_summary_formats_correctly() {
224 assert_eq!(provider_login_summary(&[]), "all logged in");
225 assert_eq!(provider_login_summary(&sample_entries()), "1 needs login");
226 }
227
228 #[test]
229 fn provider_login_summary_shows_logged_in() {
230 let entries = vec![ProviderLoginEntry {
231 method_id: "codex".to_string(),
232 name: "Codex".to_string(),
233 status: ProviderLoginStatus::LoggedIn,
234 }];
235 assert_eq!(provider_login_summary(&entries), "1 logged in");
236 }
237
238 #[test]
239 fn provider_login_summary_mixed_statuses() {
240 let entries = vec![
241 ProviderLoginEntry {
242 method_id: "a".to_string(),
243 name: "A".to_string(),
244 status: ProviderLoginStatus::NeedsLogin,
245 },
246 ProviderLoginEntry {
247 method_id: "b".to_string(),
248 name: "B".to_string(),
249 status: ProviderLoginStatus::LoggedIn,
250 },
251 ];
252 assert_eq!(provider_login_summary(&entries), "1 needs login, 1 logged in");
253 }
254
255 #[test]
256 fn set_logged_in_updates_status() {
257 let mut overlay = ProviderLoginOverlay::new(sample_entries());
258 overlay.set_logged_in("codex");
259 assert_eq!(overlay.entries()[0].status, ProviderLoginStatus::LoggedIn);
260 }
261
262 #[test]
263 fn renders_logged_in_with_check_mark() {
264 let entries = vec![ProviderLoginEntry {
265 method_id: "codex".to_string(),
266 name: "Codex".to_string(),
267 status: ProviderLoginStatus::LoggedIn,
268 }];
269 let mut overlay = ProviderLoginOverlay::new(entries);
270 let ctx = ViewContext::new((80, 24));
271 let frame = overlay.render(&ctx);
272 let text = frame.lines()[0].plain_text();
273 assert!(text.contains("✓"), "logged in should show check mark");
274 assert!(text.contains("logged in"), "should show 'logged in' text");
275 }
276
277 #[tokio::test]
278 async fn enter_on_logged_in_emits_authenticate_for_reauth() {
279 let entries = vec![ProviderLoginEntry {
280 method_id: "codex".to_string(),
281 name: "Codex".to_string(),
282 status: ProviderLoginStatus::LoggedIn,
283 }];
284 let mut overlay = ProviderLoginOverlay::new(entries);
285 let outcome = overlay.on_event(&Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))).await;
286 let messages = outcome.unwrap();
287 match messages.as_slice() {
288 [ProviderLoginMessage::Authenticate(id)] => assert_eq!(id, "codex"),
289 _ => panic!("Expected Authenticate message for re-auth"),
290 }
291 }
292}