| 1 | //! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM / |
| 2 | //! OpenRouter / Novita) and, if it lacks credentials, type the API key |
| 3 | //! inline before completing the switch (#52). |
| 4 | //! |
| 5 | //! The picker is intentionally a single modal with two visible states: |
| 6 | //! |
| 7 | //! 1. **List** — pick a provider; each row shows the active provider arrow |
| 8 | //! and an "API key configured" / "needs API key" hint. Enter on a |
| 9 | //! configured provider applies the switch immediately |
| 10 | //! ([`ViewEvent::ProviderPickerApplied`]). Enter on an un-configured one |
| 11 | //! transitions the same modal into the key-entry state. |
| 12 | //! 2. **Key entry** — masked input box pre-filled with the provider's |
| 13 | //! canonical env-var name as a hint. Enter submits |
| 14 | //! [`ViewEvent::ProviderPickerApiKeySubmitted`], which the UI handler |
| 15 | //! persists via `save_api_key_for` before switching. |
| 16 | //! |
| 17 | //! Pressing Esc backs out: from key entry returns to the list; from the |
| 18 | //! list closes the modal without changes. |
| 19 | |
| 20 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 21 | use ratatui::{ |
| 22 | buffer::Buffer, |
| 23 | layout::{Constraint, Direction, Layout, Rect}, |
| 24 | style::{Modifier, Style}, |
| 25 | text::{Line, Span}, |
| 26 | widgets::{Block, Borders, Clear, Paragraph, Widget}, |
| 27 | }; |
| 28 | |
| 29 | use crate::config::{ApiProvider, Config, has_api_key_for}; |
| 30 | use crate::palette; |
| 31 | use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; |
| 32 | |
| 33 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 34 | enum Stage { |
| 35 | List, |
| 36 | KeyEntry, |
| 37 | } |
| 38 | |
| 39 | pub struct ProviderPickerView { |
| 40 | providers: Vec<(ApiProvider, bool)>, |
| 41 | active_provider: ApiProvider, |
| 42 | selected_idx: usize, |
| 43 | stage: Stage, |
| 44 | api_key_input: String, |
| 45 | } |
| 46 | |
| 47 | impl ProviderPickerView { |
| 48 | #[must_use] |
| 49 | pub fn new(active: ApiProvider, config: &Config) -> Self { |
| 50 | let providers: Vec<(ApiProvider, bool)> = ApiProvider::all() |
| 51 | .iter() |
| 52 | .map(|p| (*p, has_api_key_for(config, *p))) |
| 53 | .collect(); |
| 54 | let selected_idx = providers |
| 55 | .iter() |
| 56 | .position(|(p, _)| *p == active) |
| 57 | .unwrap_or(0); |
| 58 | Self { |
| 59 | providers, |
| 60 | active_provider: active, |
| 61 | selected_idx, |
| 62 | stage: Stage::List, |
| 63 | api_key_input: String::new(), |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | fn move_up(&mut self) { |
| 68 | if self.selected_idx > 0 { |
| 69 | self.selected_idx -= 1; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn move_down(&mut self) { |
| 74 | if self.selected_idx + 1 < self.providers.len() { |
| 75 | self.selected_idx += 1; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | fn selected_provider(&self) -> ApiProvider { |
| 80 | self.providers[self.selected_idx].0 |
| 81 | } |
| 82 | |
| 83 | fn selected_has_key(&self) -> bool { |
| 84 | self.providers[self.selected_idx].1 |
| 85 | } |
| 86 | |
| 87 | fn env_var_for(provider: ApiProvider) -> &'static str { |
| 88 | match provider { |
| 89 | ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY", |
| 90 | ApiProvider::NvidiaNim => "NVIDIA_API_KEY", |
| 91 | ApiProvider::Openrouter => "OPENROUTER_API_KEY", |
| 92 | ApiProvider::Novita => "NOVITA_API_KEY", |
| 93 | ApiProvider::Fireworks => "FIREWORKS_API_KEY", |
| 94 | ApiProvider::Sglang => "SGLANG_API_KEY", |
| 95 | ApiProvider::Vllm => "VLLM_API_KEY", |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | fn render_list(&self, area: Rect, buf: &mut Buffer) { |
| 100 | let outer = Block::default() |
| 101 | .title(Line::from(Span::styled( |
| 102 | " Provider ", |
| 103 | Style::default() |
| 104 | .fg(palette::DEEPSEEK_SKY) |
| 105 | .add_modifier(Modifier::BOLD), |
| 106 | ))) |
| 107 | .title_bottom(Line::from(vec![ |
| 108 | Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)), |
| 109 | Span::raw("move "), |
| 110 | Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), |
| 111 | Span::raw("apply "), |
| 112 | Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), |
| 113 | Span::raw("cancel "), |
| 114 | ])) |
| 115 | .borders(Borders::ALL) |
| 116 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 117 | .style(Style::default()); |
| 118 | let inner = outer.inner(area); |
| 119 | outer.render(area, buf); |
| 120 | |
| 121 | let mut lines: Vec<Line> = Vec::with_capacity(self.providers.len()); |
| 122 | for (idx, (provider, has_key)) in self.providers.iter().enumerate() { |
| 123 | let is_selected = idx == self.selected_idx; |
| 124 | let is_active = *provider == self.active_provider; |
| 125 | let arrow = if is_selected { "▸" } else { " " }; |
| 126 | let active_dot = if is_active { " *" } else { " " }; |
| 127 | let label_style = if is_selected { |
| 128 | Style::default() |
| 129 | .fg(palette::SELECTION_TEXT) |
| 130 | .bg(palette::SELECTION_BG) |
| 131 | .add_modifier(Modifier::BOLD) |
| 132 | } else { |
| 133 | Style::default().fg(palette::TEXT_PRIMARY) |
| 134 | }; |
| 135 | let hint_style = if is_selected { |
| 136 | Style::default() |
| 137 | .fg(palette::SELECTION_TEXT) |
| 138 | .bg(palette::SELECTION_BG) |
| 139 | } else if *has_key { |
| 140 | Style::default().fg(palette::TEXT_MUTED) |
| 141 | } else { |
| 142 | Style::default().fg(palette::STATUS_WARNING) |
| 143 | }; |
| 144 | let hint = if *has_key { |
| 145 | "(configured)".to_string() |
| 146 | } else { |
| 147 | "(needs API key)".to_string() |
| 148 | }; |
| 149 | lines.push(Line::from(vec![ |
| 150 | Span::raw(" "), |
| 151 | Span::styled(arrow, label_style), |
| 152 | Span::raw(" "), |
| 153 | Span::styled(provider.display_name().to_string(), label_style), |
| 154 | Span::styled(active_dot, label_style), |
| 155 | Span::raw(" "), |
| 156 | Span::styled(hint, hint_style), |
| 157 | ])); |
| 158 | } |
| 159 | Paragraph::new(lines).render(inner, buf); |
| 160 | } |
| 161 | |
| 162 | fn render_key_entry(&self, area: Rect, buf: &mut Buffer) { |
| 163 | let provider = self.selected_provider(); |
| 164 | let outer = Block::default() |
| 165 | .title(Line::from(Span::styled( |
| 166 | format!(" API key — {} ", provider.display_name()), |
| 167 | Style::default() |
| 168 | .fg(palette::DEEPSEEK_SKY) |
| 169 | .add_modifier(Modifier::BOLD), |
| 170 | ))) |
| 171 | .title_bottom(Line::from(vec![ |
| 172 | Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)), |
| 173 | Span::raw("save & switch "), |
| 174 | Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)), |
| 175 | Span::raw("back "), |
| 176 | ])) |
| 177 | .borders(Borders::ALL) |
| 178 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 179 | .style(Style::default()); |
| 180 | let inner = outer.inner(area); |
| 181 | outer.render(area, buf); |
| 182 | |
| 183 | let layout = Layout::default() |
| 184 | .direction(Direction::Vertical) |
| 185 | .constraints([ |
| 186 | Constraint::Length(3), |
| 187 | Constraint::Length(2), |
| 188 | Constraint::Min(1), |
| 189 | ]) |
| 190 | .split(inner); |
| 191 | |
| 192 | let masked = mask_key(&self.api_key_input); |
| 193 | let display = if masked.is_empty() { |
| 194 | "(paste key here)".to_string() |
| 195 | } else { |
| 196 | masked |
| 197 | }; |
| 198 | let key_lines = vec![Line::from(vec![ |
| 199 | Span::styled("Key: ", Style::default().fg(palette::TEXT_MUTED)), |
| 200 | Span::styled( |
| 201 | display, |
| 202 | Style::default() |
| 203 | .fg(palette::TEXT_PRIMARY) |
| 204 | .add_modifier(Modifier::BOLD), |
| 205 | ), |
| 206 | ])]; |
| 207 | Paragraph::new(key_lines).render(layout[0], buf); |
| 208 | |
| 209 | let hint = format!( |
| 210 | "Or set the {} environment variable and re-open /provider.", |
| 211 | Self::env_var_for(provider), |
| 212 | ); |
| 213 | Paragraph::new(Line::from(Span::styled( |
| 214 | hint, |
| 215 | Style::default().fg(palette::TEXT_MUTED), |
| 216 | ))) |
| 217 | .render(layout[1], buf); |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | fn mask_key(input: &str) -> String { |
| 222 | let trimmed = input.trim(); |
| 223 | let len = trimmed.chars().count(); |
| 224 | if len == 0 { |
| 225 | return String::new(); |
| 226 | } |
| 227 | if len <= 4 { |
| 228 | return "*".repeat(len); |
| 229 | } |
| 230 | let visible: String = trimmed |
| 231 | .chars() |
| 232 | .rev() |
| 233 | .take(4) |
| 234 | .collect::<String>() |
| 235 | .chars() |
| 236 | .rev() |
| 237 | .collect(); |
| 238 | format!("{}{}", "*".repeat(len - 4), visible) |
| 239 | } |
| 240 | |
| 241 | impl ModalView for ProviderPickerView { |
| 242 | fn kind(&self) -> ModalKind { |
| 243 | ModalKind::ProviderPicker |
| 244 | } |
| 245 | |
| 246 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 247 | self |
| 248 | } |
| 249 | |
| 250 | fn handle_paste(&mut self, text: &str) -> bool { |
| 251 | if self.stage == Stage::KeyEntry { |
| 252 | let sanitized: String = text.chars().filter(|c| !c.is_whitespace()).collect(); |
| 253 | if !sanitized.is_empty() { |
| 254 | self.api_key_input.push_str(&sanitized); |
| 255 | } |
| 256 | true |
| 257 | } else { |
| 258 | false |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 263 | match self.stage { |
| 264 | Stage::List => match key.code { |
| 265 | KeyCode::Esc => ViewAction::Close, |
| 266 | KeyCode::Up => { |
| 267 | self.move_up(); |
| 268 | ViewAction::None |
| 269 | } |
| 270 | KeyCode::Down => { |
| 271 | self.move_down(); |
| 272 | ViewAction::None |
| 273 | } |
| 274 | KeyCode::Enter => { |
| 275 | let provider = self.selected_provider(); |
| 276 | if self.selected_has_key() { |
| 277 | ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) |
| 278 | } else { |
| 279 | self.stage = Stage::KeyEntry; |
| 280 | self.api_key_input.clear(); |
| 281 | ViewAction::None |
| 282 | } |
| 283 | } |
| 284 | _ => ViewAction::None, |
| 285 | }, |
| 286 | Stage::KeyEntry => match key.code { |
| 287 | KeyCode::Esc => { |
| 288 | self.stage = Stage::List; |
| 289 | self.api_key_input.clear(); |
| 290 | ViewAction::None |
| 291 | } |
| 292 | KeyCode::Backspace => { |
| 293 | self.api_key_input.pop(); |
| 294 | ViewAction::None |
| 295 | } |
| 296 | KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 297 | self.api_key_input.pop(); |
| 298 | ViewAction::None |
| 299 | } |
| 300 | KeyCode::Enter => { |
| 301 | let key = self.api_key_input.trim().to_string(); |
| 302 | if key.is_empty() { |
| 303 | // Stay in key-entry; the user can press Esc to abort. |
| 304 | ViewAction::None |
| 305 | } else { |
| 306 | let provider = self.selected_provider(); |
| 307 | ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted { |
| 308 | provider, |
| 309 | api_key: key, |
| 310 | }) |
| 311 | } |
| 312 | } |
| 313 | KeyCode::Char(c) => { |
| 314 | // Reject ASCII whitespace so a stray space/tab doesn't slip |
| 315 | // into a credential; bracketed paste happens via the input |
| 316 | // path that already trims on submit. |
| 317 | if !c.is_whitespace() { |
| 318 | self.api_key_input.push(c); |
| 319 | } |
| 320 | ViewAction::None |
| 321 | } |
| 322 | _ => ViewAction::None, |
| 323 | }, |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 328 | let popup_width = 64.min(area.width.saturating_sub(4)).max(40); |
| 329 | let popup_height = match self.stage { |
| 330 | Stage::List => 12, |
| 331 | Stage::KeyEntry => 10, |
| 332 | } |
| 333 | .min(area.height.saturating_sub(4)) |
| 334 | .max(8); |
| 335 | let popup_area = Rect { |
| 336 | x: area.x + (area.width.saturating_sub(popup_width)) / 2, |
| 337 | y: area.y + (area.height.saturating_sub(popup_height)) / 2, |
| 338 | width: popup_width, |
| 339 | height: popup_height, |
| 340 | }; |
| 341 | |
| 342 | Clear.render(popup_area, buf); |
| 343 | |
| 344 | match self.stage { |
| 345 | Stage::List => self.render_list(popup_area, buf), |
| 346 | Stage::KeyEntry => self.render_key_entry(popup_area, buf), |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | #[cfg(test)] |
| 352 | mod tests { |
| 353 | use super::*; |
| 354 | use crossterm::event::{KeyEvent, KeyModifiers}; |
| 355 | |
| 356 | fn key(code: KeyCode) -> KeyEvent { |
| 357 | KeyEvent::new(code, KeyModifiers::NONE) |
| 358 | } |
| 359 | |
| 360 | #[test] |
| 361 | fn picker_lists_all_seven_providers() { |
| 362 | let config = Config::default(); |
| 363 | let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 364 | let names: Vec<_> = picker |
| 365 | .providers |
| 366 | .iter() |
| 367 | .map(|(p, _)| p.display_name()) |
| 368 | .collect(); |
| 369 | assert_eq!( |
| 370 | names, |
| 371 | vec![ |
| 372 | "DeepSeek", |
| 373 | "DeepSeek (中国)", |
| 374 | "NVIDIA NIM", |
| 375 | "OpenRouter", |
| 376 | "Novita AI", |
| 377 | "Fireworks AI", |
| 378 | "SGLang", |
| 379 | "vLLM" |
| 380 | ] |
| 381 | ); |
| 382 | } |
| 383 | |
| 384 | #[test] |
| 385 | fn picker_marks_active_provider_as_initial_selection() { |
| 386 | let config = Config::default(); |
| 387 | let picker = ProviderPickerView::new(ApiProvider::Openrouter, &config); |
| 388 | assert_eq!(picker.selected_provider(), ApiProvider::Openrouter); |
| 389 | assert_eq!(picker.active_provider, ApiProvider::Openrouter); |
| 390 | } |
| 391 | |
| 392 | #[test] |
| 393 | fn enter_with_no_key_transitions_to_key_entry_stage() { |
| 394 | let config = Config::default(); |
| 395 | let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 396 | // Move to OpenRouter (index 3), which has no key in default config. |
| 397 | picker.handle_key(key(KeyCode::Down)); |
| 398 | picker.handle_key(key(KeyCode::Down)); |
| 399 | picker.handle_key(key(KeyCode::Down)); |
| 400 | assert_eq!(picker.selected_provider(), ApiProvider::Openrouter); |
| 401 | let action = picker.handle_key(key(KeyCode::Enter)); |
| 402 | assert!(matches!(action, ViewAction::None)); |
| 403 | assert_eq!(picker.stage, Stage::KeyEntry); |
| 404 | } |
| 405 | |
| 406 | #[test] |
| 407 | fn enter_with_existing_key_emits_apply_and_closes() { |
| 408 | let config = Config { |
| 409 | api_key: Some("existing-deepseek-key".to_string()), |
| 410 | ..Config::default() |
| 411 | }; |
| 412 | let mut picker = ProviderPickerView::new(ApiProvider::NvidiaNim, &config); |
| 413 | // Move up twice to DeepSeek (index 0), which has a key from the config. |
| 414 | picker.handle_key(key(KeyCode::Up)); |
| 415 | picker.handle_key(key(KeyCode::Up)); |
| 416 | let action = picker.handle_key(key(KeyCode::Enter)); |
| 417 | match action { |
| 418 | ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) => { |
| 419 | assert_eq!(provider, ApiProvider::Deepseek); |
| 420 | } |
| 421 | other => panic!("expected ProviderPickerApplied, got {other:?}"), |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | #[test] |
| 426 | fn key_entry_enter_submits_after_typing() { |
| 427 | let config = Config::default(); |
| 428 | let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 429 | // Navigate to Novita (index 4) and trigger key entry. |
| 430 | for _ in 0..4 { |
| 431 | picker.handle_key(key(KeyCode::Down)); |
| 432 | } |
| 433 | picker.handle_key(key(KeyCode::Enter)); |
| 434 | assert_eq!(picker.stage, Stage::KeyEntry); |
| 435 | for c in "novita-key".chars() { |
| 436 | picker.handle_key(key(KeyCode::Char(c))); |
| 437 | } |
| 438 | let action = picker.handle_key(key(KeyCode::Enter)); |
| 439 | match action { |
| 440 | ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted { |
| 441 | provider, |
| 442 | api_key, |
| 443 | }) => { |
| 444 | assert_eq!(provider, ApiProvider::Novita); |
| 445 | assert_eq!(api_key, "novita-key"); |
| 446 | } |
| 447 | other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"), |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | #[test] |
| 452 | fn key_entry_esc_returns_to_list_without_emitting() { |
| 453 | let config = Config::default(); |
| 454 | let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 455 | picker.handle_key(key(KeyCode::Down)); |
| 456 | picker.handle_key(key(KeyCode::Down)); |
| 457 | picker.handle_key(key(KeyCode::Down)); |
| 458 | picker.handle_key(key(KeyCode::Enter)); |
| 459 | assert_eq!(picker.stage, Stage::KeyEntry); |
| 460 | picker.handle_key(key(KeyCode::Char('a'))); |
| 461 | let action = picker.handle_key(key(KeyCode::Esc)); |
| 462 | assert!(matches!(action, ViewAction::None)); |
| 463 | assert_eq!(picker.stage, Stage::List); |
| 464 | assert!(picker.api_key_input.is_empty()); |
| 465 | } |
| 466 | |
| 467 | #[test] |
| 468 | fn list_esc_closes_without_emitting() { |
| 469 | let config = Config::default(); |
| 470 | let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 471 | let action = picker.handle_key(key(KeyCode::Esc)); |
| 472 | assert!(matches!(action, ViewAction::Close)); |
| 473 | } |
| 474 | |
| 475 | #[test] |
| 476 | fn key_entry_strips_whitespace_chars() { |
| 477 | let config = Config::default(); |
| 478 | let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); |
| 479 | picker.handle_key(key(KeyCode::Down)); |
| 480 | picker.handle_key(key(KeyCode::Down)); |
| 481 | picker.handle_key(key(KeyCode::Down)); |
| 482 | picker.handle_key(key(KeyCode::Enter)); |
| 483 | assert_eq!(picker.stage, Stage::KeyEntry); |
| 484 | for c in "abc def".chars() { |
| 485 | picker.handle_key(key(KeyCode::Char(c))); |
| 486 | } |
| 487 | assert_eq!(picker.api_key_input, "abcdef"); |
| 488 | } |
| 489 | } |
| 490 |