Skip to main content

rab/agent/ui/components/
oauth_selector.rs

1//! OAuthSelector component — matching pi's OAuthSelectorComponent.
2//!
3//! Provider selector with search for login/logout flows.
4//! Shows provider name, ID, and current auth status (configured, env, etc.).
5//! Supports fuzzy filtering via search input.
6
7use crate::agent::ui::theme::ThemeKey;
8use crate::agent::ui::theme::current_theme;
9use crate::tui::Component;
10use crate::tui::fuzzy::fuzzy_filter;
11use crate::tui::keybindings::{
12    ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM, ACTION_SELECT_DOWN, ACTION_SELECT_UP,
13    get_keybindings,
14};
15use crossterm::event::{KeyCode, KeyEvent};
16
17// ── Provider item types ────────────────────────────────────────────
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum AuthType {
21    OAuth,
22    ApiKey,
23}
24
25impl AuthType {
26    fn as_str(&self) -> &'static str {
27        match self {
28            AuthType::OAuth => "oauth",
29            AuthType::ApiKey => "api_key",
30        }
31    }
32}
33
34/// A provider option shown in the selector (matching pi's AuthSelectorProvider).
35#[derive(Debug, Clone)]
36pub struct AuthSelectorProvider {
37    pub id: String,
38    pub name: String,
39    pub auth_type: AuthType,
40}
41
42/// Login or logout mode for the selector.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum SelectorMode {
45    Login,
46    Logout,
47}
48
49// ── Internal provider item ─────────────────────────────────────────
50
51#[derive(Clone)]
52#[allow(dead_code)]
53struct ProviderItem {
54    id: String,
55    name: String,
56    auth_type: AuthType,
57    /// Whether the provider has matching credentials stored.
58    has_stored: bool,
59    /// Whether some other auth is available (env var, runtime, etc.)
60    has_other_auth: bool,
61    /// Label for the auth status, e.g. "configured", "env: API_KEY", "unconfigured"
62    status_label: String,
63    /// Pre-computed search text.
64    search_text: String,
65}
66
67impl ProviderItem {
68    fn search_text(&self) -> &str {
69        &self.search_text
70    }
71}
72
73// ── Auth status function ───────────────────────────────────────────
74
75/// Status information for a provider (matching pi's AuthStatus interface).
76pub struct ProviderAuthStatus {
77    pub configured: bool,
78    pub source: Option<String>,
79    pub label: Option<String>,
80}
81
82// ── OAuthSelector component ────────────────────────────────────────
83
84/// Provider selector with search — matching pi's OAuthSelectorComponent.
85pub struct OAuthSelector {
86    mode: SelectorMode,
87    items: Vec<ProviderItem>,
88    filtered_indices: Vec<usize>,
89    selected_index: usize,
90    search_query: String,
91    max_visible: usize,
92    /// Callbacks
93    on_select: Option<Box<dyn FnOnce(String)>>, // provider ID
94    on_cancel: Option<Box<dyn FnOnce()>>,
95}
96
97impl OAuthSelector {
98    /// Create a new OAuth selector.
99    ///
100    /// `providers` — list of providers available for login/logout.
101    /// `auth_status` — function returning auth status for each provider.
102    /// `mode` — login or logout mode.
103    pub fn new(
104        providers: Vec<AuthSelectorProvider>,
105        auth_status: impl Fn(&str) -> ProviderAuthStatus,
106        mode: SelectorMode,
107    ) -> Self {
108        let items: Vec<ProviderItem> = providers
109            .into_iter()
110            .map(|p| {
111                let status = auth_status(&p.id);
112                let has_stored =
113                    status.configured && matches!(status.source.as_deref(), Some("stored"));
114                let has_other_auth = status.configured && !has_stored;
115                let status_label = if has_stored {
116                    "configured".to_string()
117                } else if let Some(label) = status.label {
118                    format!("env: {}", label)
119                } else if status.configured {
120                    "configured".to_string()
121                } else {
122                    "unconfigured".to_string()
123                };
124                let id = p.id;
125                let name = p.name;
126                let auth_type = p.auth_type;
127                let search_text =
128                    format!("{} {} {} {}", id, name, auth_type.as_str(), status_label);
129                ProviderItem {
130                    id,
131                    name,
132                    auth_type,
133                    has_stored,
134                    has_other_auth,
135                    status_label,
136                    search_text,
137                }
138            })
139            .collect();
140
141        // Sort: stored first, then other auth, then alphabetically
142        let mut sorted = items;
143        sorted.sort_by(|a, b| {
144            let a_priority = if a.has_stored {
145                0
146            } else if a.has_other_auth {
147                1
148            } else {
149                2
150            };
151            let b_priority = if b.has_stored {
152                0
153            } else if b.has_other_auth {
154                1
155            } else {
156                2
157            };
158            a_priority.cmp(&b_priority).then(a.name.cmp(&b.name))
159        });
160
161        let filtered: Vec<usize> = (0..sorted.len()).collect();
162
163        Self {
164            mode,
165            items: sorted,
166            filtered_indices: filtered,
167            selected_index: 0,
168            search_query: String::new(),
169            max_visible: 10,
170            on_select: None,
171            on_cancel: None,
172        }
173    }
174
175    /// Set the callback for when a provider is selected.
176    pub fn on_select<F>(&mut self, f: F)
177    where
178        F: FnOnce(String) + 'static,
179    {
180        self.on_select = Some(Box::new(f));
181    }
182
183    /// Set the callback for when the user cancels.
184    pub fn on_cancel<F>(&mut self, f: F)
185    where
186        F: FnOnce() + 'static,
187    {
188        self.on_cancel = Some(Box::new(f));
189    }
190
191    fn refresh(&mut self) {
192        let query = self.search_query.clone();
193        self.filtered_indices = if query.trim().is_empty() {
194            (0..self.items.len()).collect()
195        } else {
196            fuzzy_filter(&self.items, &query, |item| item.search_text())
197        };
198        self.selected_index = self
199            .selected_index
200            .min(self.filtered_indices.len().saturating_sub(1));
201    }
202
203    fn get_item(&self, filtered_idx: usize) -> Option<&ProviderItem> {
204        self.filtered_indices
205            .get(filtered_idx)
206            .and_then(|&idx| self.items.get(idx))
207    }
208}
209
210impl Component for OAuthSelector {
211    fn render(&mut self, width: usize) -> Vec<String> {
212        use crate::tui::util::truncate_to_width;
213        let theme = current_theme();
214        let mut lines: Vec<String> = Vec::new();
215
216        // Top border (matches pi's DynamicBorder)
217        lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
218        lines.push(String::new());
219
220        // Title (matches pi's TruncatedText with theme.fg("accent", theme.bold(title)))
221        let title = match self.mode {
222            SelectorMode::Login => "Select provider to configure:",
223            SelectorMode::Logout => "Select provider to logout:",
224        };
225        lines.push(format!(
226            "  {}",
227            theme.bold(&theme.fg_key(ThemeKey::Accent, title))
228        ));
229        lines.push(String::new());
230
231        // Search input line
232        let search_value = if self.search_query.is_empty() {
233            String::new()
234        } else {
235            self.search_query.clone()
236        };
237        lines.push(format!(" {}{}", theme.dim("Search: "), search_value));
238        lines.push(String::new());
239
240        // Provider list
241        let count = self.filtered_indices.len();
242        if count == 0 {
243            let msg = if self.items.is_empty() {
244                match self.mode {
245                    SelectorMode::Login => "No providers available",
246                    SelectorMode::Logout => "No providers logged in. Use /login first.",
247                }
248            } else {
249                "No matching providers"
250            };
251            lines.push(theme.dim(&format!("  {}", msg)));
252        } else {
253            let start = self
254                .selected_index
255                .saturating_sub(self.max_visible / 2)
256                .min(count.saturating_sub(self.max_visible));
257            let end = (start + self.max_visible).min(count);
258
259            for i in start..end {
260                let item = &self.items[self.filtered_indices[i]];
261                let is_selected = i == self.selected_index;
262
263                let prefix = if is_selected {
264                    theme.fg_key(ThemeKey::Accent, "→ ")
265                } else {
266                    "  ".to_string()
267                };
268                let name_text = if is_selected {
269                    theme.fg_key(ThemeKey::Accent, &item.name)
270                } else {
271                    theme.fg_key(ThemeKey::Text, &item.name)
272                };
273
274                // Status indicator (matching pi's formatStatusIndicator)
275                let status = if item.has_stored {
276                    // Exact credential match (credential type == provider auth type)
277                    theme.success(" ✓ configured")
278                } else if item.has_other_auth {
279                    // Env var or other non-stored auth source
280                    theme.success(&format!(" ✓ {}", item.status_label))
281                } else {
282                    theme.dim(" • unconfigured")
283                };
284
285                lines.push(truncate_to_width(
286                    &format!("{}{}{}", prefix, name_text, status),
287                    width.saturating_sub(4),
288                    "",
289                    false,
290                ));
291            }
292
293            // Scroll indicator
294            if count > self.max_visible {
295                lines.push(theme.dim(&format!("  ({}/{})", self.selected_index + 1, count)));
296            }
297        }
298
299        // Hints
300        lines.push(String::new());
301        lines.push(format!(
302            "  {}",
303            theme.dim("Enter: select · Esc: cancel · Type to search")
304        ));
305        lines.push(String::new());
306
307        // Bottom border
308        lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
309
310        lines
311    }
312
313    fn handle_input(&mut self, key: &KeyEvent) -> bool {
314        let kb = get_keybindings();
315
316        // Up/Down navigation with wrapping
317        if kb.matches(key, ACTION_SELECT_UP) {
318            if self.filtered_indices.is_empty() {
319                return true;
320            }
321            self.selected_index = if self.selected_index == 0 {
322                self.filtered_indices.len() - 1
323            } else {
324                self.selected_index - 1
325            };
326            return true;
327        }
328
329        if kb.matches(key, ACTION_SELECT_DOWN) {
330            if self.filtered_indices.is_empty() {
331                return true;
332            }
333            self.selected_index = if self.selected_index >= self.filtered_indices.len() - 1 {
334                0
335            } else {
336                self.selected_index + 1
337            };
338            return true;
339        }
340
341        // Enter selects provider
342        if kb.matches(key, ACTION_SELECT_CONFIRM) {
343            let selected_id = self
344                .get_item(self.selected_index)
345                .map(|item| item.id.clone());
346            if let Some(id) = selected_id
347                && let Some(cb) = self.on_select.take()
348            {
349                cb(id);
350            }
351            return true;
352        }
353
354        // Escape cancels
355        if kb.matches(key, ACTION_SELECT_CANCEL) {
356            if let Some(cb) = self.on_cancel.take() {
357                cb();
358            }
359            return false;
360        }
361
362        // Backspace - delete from search
363        if key.code == KeyCode::Backspace {
364            if !self.search_query.is_empty() {
365                self.search_query.pop();
366                self.refresh();
367            }
368            return true;
369        }
370
371        // Typeable characters go to search
372        if let KeyCode::Char(c) = key.code
373            && !c.is_control()
374        {
375            self.search_query.push(c);
376            self.refresh();
377            return true;
378        }
379
380        false
381    }
382}