Skip to main content

wisp/components/
provider_login.rs

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}