1use 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#[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#[derive(Debug, Clone)]
36pub struct AuthSelectorProvider {
37 pub id: String,
38 pub name: String,
39 pub auth_type: AuthType,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum SelectorMode {
45 Login,
46 Logout,
47}
48
49#[derive(Clone)]
52#[allow(dead_code)]
53struct ProviderItem {
54 id: String,
55 name: String,
56 auth_type: AuthType,
57 has_stored: bool,
59 has_other_auth: bool,
61 status_label: String,
63 search_text: String,
65}
66
67impl ProviderItem {
68 fn search_text(&self) -> &str {
69 &self.search_text
70 }
71}
72
73pub struct ProviderAuthStatus {
77 pub configured: bool,
78 pub source: Option<String>,
79 pub label: Option<String>,
80}
81
82pub 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 on_select: Option<Box<dyn FnOnce(String)>>, on_cancel: Option<Box<dyn FnOnce()>>,
95}
96
97impl OAuthSelector {
98 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 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 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 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 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
218 lines.push(String::new());
219
220 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 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 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 let status = if item.has_stored {
276 theme.success(" ✓ configured")
278 } else if item.has_other_auth {
279 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 if count > self.max_visible {
295 lines.push(theme.dim(&format!(" ({}/{})", self.selected_index + 1, count)));
296 }
297 }
298
299 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 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 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 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 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 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 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}