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(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}