返回 DeepSeek-TUI-2026
provider_picker.rs
根目录 / crates / tui / src / tui / provider_picker.rs
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
490 lines RUST