e6005eb9ac
* feat(config): add first-party Z.ai and StepFlash/StepFun provider routes (#3187) Add Z.ai (GLM Coding Plan) and StepFun/StepFlash as first-party providers: Provider: Z.ai - Default model: GLM-5.1 (200K context, 128K output, thinking, function calling) - Base URL: https://api.z.ai/api/coding/paas/v4 - Auth: ZAI_API_KEY / Z_AI_API_KEY - Config key: [providers.zai] Provider: StepFun - Default model: step-3.7-flash (256K context, 256K output, 3-level reasoning) - Base URL: https://api.stepfun.ai/v1 - Auth: STEPFUN_API_KEY / STEP_API_KEY - Config key: [providers.stepfun] Both providers use OpenAI-compatible Chat Completions transport. Includes: - ProviderKind enum variants with serde aliases - ProvidersToml config fields with aliases - Provider trait implementations in provider.rs - PROVIDER_REGISTRY, ALL, KIND_LOOKUP, FROM_KIND_LOOKUP updates - EnvRuntimeOverrides fields and env-var loading - TUI ApiProvider enum and lookup table updates - CLI provider_slot and provider_env_vars additions - Exhaustive match arms across client.rs, config.rs, engine.rs, main.rs, provider_picker.rs, ui.rs, and config_persistence.rs Verified: cargo check passes, config provider tests (67/67) pass. * test(tui): update provider picker tests for Zai and Stepfun additions
753 lines
27 KiB
Rust
753 lines
27 KiB
Rust
//! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM /
|
|
//! hosted providers / self-hosted providers) and, if it lacks credentials, type the API key
|
|
//! inline before completing the switch (#52).
|
|
//!
|
|
//! The picker is intentionally a single modal with two visible states:
|
|
//!
|
|
//! 1. **List** — pick a provider; each row shows the active provider arrow
|
|
//! and an "API key configured" / "needs API key" hint. Enter on a
|
|
//! configured provider applies the switch immediately
|
|
//! ([`ViewEvent::ProviderPickerApplied`]). Enter on an un-configured one
|
|
//! transitions the same modal into the key-entry state.
|
|
//! 2. **Key entry** — masked input box pre-filled with the provider's
|
|
//! canonical env-var name as a hint. Enter submits
|
|
//! [`ViewEvent::ProviderPickerApiKeySubmitted`], which the UI handler
|
|
//! persists via `save_api_key_for` before switching.
|
|
//!
|
|
//! Pressing Esc backs out: from key entry returns to the list; from the
|
|
//! list closes the modal without changes.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::{
|
|
buffer::Buffer,
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
|
};
|
|
|
|
use crate::config::{ApiProvider, Config, has_api_key_for, kimi_cli_credentials_present};
|
|
use crate::palette;
|
|
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Stage {
|
|
List,
|
|
KeyEntry,
|
|
}
|
|
|
|
pub struct ProviderPickerView {
|
|
providers: Vec<(ApiProvider, bool)>,
|
|
active_provider: ApiProvider,
|
|
selected_idx: usize,
|
|
stage: Stage,
|
|
api_key_input: String,
|
|
}
|
|
|
|
impl ProviderPickerView {
|
|
#[must_use]
|
|
pub fn new(active: ApiProvider, config: &Config) -> Self {
|
|
let providers: Vec<(ApiProvider, bool)> = ApiProvider::all()
|
|
.iter()
|
|
.map(|p| (*p, has_api_key_for(config, *p)))
|
|
.collect();
|
|
let selected_idx = providers
|
|
.iter()
|
|
.position(|(p, _)| *p == active)
|
|
.unwrap_or(0);
|
|
Self {
|
|
providers,
|
|
active_provider: active,
|
|
selected_idx,
|
|
stage: Stage::List,
|
|
api_key_input: String::new(),
|
|
}
|
|
}
|
|
|
|
fn move_up(&mut self) {
|
|
if self.providers.is_empty() {
|
|
return;
|
|
}
|
|
if self.selected_idx == 0 {
|
|
self.selected_idx = self.providers.len() - 1;
|
|
} else {
|
|
self.selected_idx -= 1;
|
|
}
|
|
}
|
|
|
|
fn move_down(&mut self) {
|
|
if self.providers.is_empty() {
|
|
return;
|
|
}
|
|
if self.selected_idx + 1 == self.providers.len() {
|
|
self.selected_idx = 0;
|
|
} else {
|
|
self.selected_idx += 1;
|
|
}
|
|
}
|
|
|
|
fn selected_provider(&self) -> ApiProvider {
|
|
self.providers[self.selected_idx].0
|
|
}
|
|
|
|
fn selected_has_key(&self) -> bool {
|
|
self.providers[self.selected_idx].1
|
|
}
|
|
|
|
fn enter_key_entry(&mut self) {
|
|
self.stage = Stage::KeyEntry;
|
|
self.api_key_input.clear();
|
|
}
|
|
|
|
fn env_var_for(provider: ApiProvider) -> &'static str {
|
|
match provider {
|
|
ApiProvider::Deepseek | ApiProvider::DeepseekCN => "DEEPSEEK_API_KEY",
|
|
ApiProvider::NvidiaNim => "NVIDIA_API_KEY",
|
|
ApiProvider::Openai => "OPENAI_API_KEY",
|
|
ApiProvider::Anthropic => "ANTHROPIC_API_KEY",
|
|
ApiProvider::Atlascloud => "ATLASCLOUD_API_KEY",
|
|
ApiProvider::WanjieArk => "WANJIE_ARK_API_KEY",
|
|
ApiProvider::Volcengine => "VOLCENGINE_API_KEY",
|
|
ApiProvider::Openrouter => "OPENROUTER_API_KEY",
|
|
ApiProvider::XiaomiMimo => "XIAOMI_MIMO_API_KEY / XIAOMI_API_KEY / MIMO_API_KEY",
|
|
ApiProvider::Novita => "NOVITA_API_KEY",
|
|
ApiProvider::Fireworks => "FIREWORKS_API_KEY",
|
|
ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => "SILICONFLOW_API_KEY",
|
|
ApiProvider::Arcee => "ARCEE_API_KEY",
|
|
ApiProvider::Moonshot => "MOONSHOT_API_KEY / KIMI_API_KEY",
|
|
ApiProvider::Sglang => "SGLANG_API_KEY",
|
|
ApiProvider::Vllm => "VLLM_API_KEY",
|
|
ApiProvider::Ollama => "OLLAMA_API_KEY",
|
|
ApiProvider::Huggingface => "HUGGINGFACE_API_KEY / HF_TOKEN",
|
|
ApiProvider::Together => "TOGETHER_API_KEY",
|
|
ApiProvider::OpenaiCodex => "OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN",
|
|
ApiProvider::Zai | ApiProvider::Stepfun => "OPENAI_API_KEY",
|
|
}
|
|
}
|
|
|
|
fn provider_hint(provider: ApiProvider, has_key: bool) -> String {
|
|
match provider {
|
|
ApiProvider::Moonshot if kimi_cli_credentials_present() => {
|
|
"(Kimi CLI OAuth ready)".to_string()
|
|
}
|
|
ApiProvider::XiaomiMimo if has_key => "(configured; token-plan endpoint)".to_string(),
|
|
ApiProvider::XiaomiMimo => {
|
|
"(needs API key; token-plan endpoint by default)".to_string()
|
|
}
|
|
ApiProvider::Ollama => "self-hosted; defaults to http://localhost:11434".to_string(),
|
|
ApiProvider::Sglang | ApiProvider::Vllm if has_key => {
|
|
"(configured; optional key)".to_string()
|
|
}
|
|
ApiProvider::Sglang | ApiProvider::Vllm => "(optional key)".to_string(),
|
|
_ if has_key => "(configured)".to_string(),
|
|
_ => "(needs API key)".to_string(),
|
|
}
|
|
}
|
|
|
|
fn visible_start(&self, visible_rows: usize) -> usize {
|
|
if visible_rows == 0 {
|
|
return 0;
|
|
}
|
|
let max_start = self.providers.len().saturating_sub(visible_rows);
|
|
self.selected_idx
|
|
.saturating_add(1)
|
|
.saturating_sub(visible_rows)
|
|
.min(max_start)
|
|
}
|
|
|
|
fn selected_row_style(fg: Color) -> Style {
|
|
Style::default()
|
|
.fg(fg)
|
|
.bg(palette::SURFACE_ELEVATED)
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
fn selected_row_bg_style() -> Style {
|
|
Style::default().bg(palette::SURFACE_ELEVATED)
|
|
}
|
|
|
|
fn render_list(&self, area: Rect, buf: &mut Buffer) {
|
|
let enter_action = if self.selected_has_key() {
|
|
"apply"
|
|
} else {
|
|
"set key"
|
|
};
|
|
let outer = Block::default()
|
|
.title(Line::from(Span::styled(
|
|
" Provider ",
|
|
Style::default()
|
|
.fg(palette::DEEPSEEK_SKY)
|
|
.add_modifier(Modifier::BOLD),
|
|
)))
|
|
.title_bottom(Line::from(vec![
|
|
Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw("move "),
|
|
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw(format!("{enter_action} ")),
|
|
Span::styled(" R ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw("edit key "),
|
|
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw("cancel "),
|
|
]))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
|
.style(Style::default());
|
|
let inner = outer.inner(area);
|
|
outer.render(area, buf);
|
|
|
|
let visible_rows = usize::from(inner.height);
|
|
let visible_start = self.visible_start(visible_rows);
|
|
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
|
|
for (idx, (provider, has_key)) in self
|
|
.providers
|
|
.iter()
|
|
.enumerate()
|
|
.skip(visible_start)
|
|
.take(visible_rows)
|
|
{
|
|
let is_selected = idx == self.selected_idx;
|
|
let is_active = *provider == self.active_provider;
|
|
let arrow = if is_selected { "▸" } else { " " };
|
|
let active_dot = if is_active { " *" } else { " " };
|
|
let spacer_style = if is_selected {
|
|
Self::selected_row_bg_style()
|
|
} else {
|
|
Style::default()
|
|
};
|
|
let label_style = if is_selected {
|
|
Self::selected_row_style(palette::TEXT_PRIMARY)
|
|
} else {
|
|
Style::default().fg(palette::TEXT_PRIMARY)
|
|
};
|
|
let hint_style = if is_selected {
|
|
let hint_fg = if *has_key {
|
|
palette::TEXT_MUTED
|
|
} else {
|
|
palette::STATUS_WARNING
|
|
};
|
|
Self::selected_row_style(hint_fg)
|
|
} else if *has_key {
|
|
Style::default().fg(palette::TEXT_MUTED)
|
|
} else {
|
|
Style::default().fg(palette::STATUS_WARNING)
|
|
};
|
|
let hint = Self::provider_hint(*provider, *has_key);
|
|
let mut line = Line::from(vec![
|
|
Span::styled(" ", spacer_style),
|
|
Span::styled(arrow, label_style),
|
|
Span::styled(" ", spacer_style),
|
|
Span::styled(provider.display_name().to_string(), label_style),
|
|
Span::styled(active_dot, label_style),
|
|
Span::styled(" ", spacer_style),
|
|
Span::styled(hint, hint_style),
|
|
]);
|
|
if is_selected {
|
|
line.style = Self::selected_row_bg_style();
|
|
let target_width = usize::from(inner.width);
|
|
let line_width = line.width();
|
|
if line_width < target_width {
|
|
line.spans.push(Span::styled(
|
|
" ".repeat(target_width - line_width),
|
|
Self::selected_row_bg_style(),
|
|
));
|
|
}
|
|
}
|
|
lines.push(line);
|
|
}
|
|
Paragraph::new(lines).render(inner, buf);
|
|
}
|
|
|
|
fn render_key_entry(&self, area: Rect, buf: &mut Buffer) {
|
|
let provider = self.selected_provider();
|
|
let outer = Block::default()
|
|
.title(Line::from(Span::styled(
|
|
format!(" API key — {} ", provider.display_name()),
|
|
Style::default()
|
|
.fg(palette::DEEPSEEK_SKY)
|
|
.add_modifier(Modifier::BOLD),
|
|
)))
|
|
.title_bottom(Line::from(vec![
|
|
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw("save & switch "),
|
|
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::raw("back "),
|
|
]))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
|
.style(Style::default());
|
|
let inner = outer.inner(area);
|
|
outer.render(area, buf);
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3),
|
|
Constraint::Length(2),
|
|
Constraint::Min(1),
|
|
])
|
|
.split(inner);
|
|
|
|
let masked = mask_key(&self.api_key_input);
|
|
let display = if masked.is_empty() {
|
|
"(paste key here)".to_string()
|
|
} else {
|
|
masked
|
|
};
|
|
let key_lines = vec![Line::from(vec![
|
|
Span::styled("Key: ", Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::styled(
|
|
display,
|
|
Style::default()
|
|
.fg(palette::TEXT_PRIMARY)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
])];
|
|
Paragraph::new(key_lines).render(layout[0], buf);
|
|
|
|
let hint = format!(
|
|
"Or set the {} environment variable and re-open /provider.",
|
|
Self::env_var_for(provider),
|
|
);
|
|
Paragraph::new(Line::from(Span::styled(
|
|
hint,
|
|
Style::default().fg(palette::TEXT_MUTED),
|
|
)))
|
|
.render(layout[1], buf);
|
|
}
|
|
}
|
|
|
|
fn mask_key(input: &str) -> String {
|
|
let trimmed = input.trim();
|
|
let len = trimmed.chars().count();
|
|
if len == 0 {
|
|
return String::new();
|
|
}
|
|
if len <= 4 {
|
|
return "*".repeat(len);
|
|
}
|
|
let visible: String = trimmed
|
|
.chars()
|
|
.rev()
|
|
.take(4)
|
|
.collect::<String>()
|
|
.chars()
|
|
.rev()
|
|
.collect();
|
|
format!("{}{}", "*".repeat(len - 4), visible)
|
|
}
|
|
|
|
impl ModalView for ProviderPickerView {
|
|
fn kind(&self) -> ModalKind {
|
|
ModalKind::ProviderPicker
|
|
}
|
|
|
|
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
|
self
|
|
}
|
|
|
|
fn handle_paste(&mut self, text: &str) -> bool {
|
|
if self.stage == Stage::KeyEntry {
|
|
let sanitized: String = text.chars().filter(|c| !c.is_whitespace()).collect();
|
|
if !sanitized.is_empty() {
|
|
self.api_key_input.push_str(&sanitized);
|
|
}
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
|
match self.stage {
|
|
Stage::List => match key.code {
|
|
KeyCode::Esc => ViewAction::Close,
|
|
KeyCode::Up => {
|
|
self.move_up();
|
|
ViewAction::None
|
|
}
|
|
KeyCode::Down => {
|
|
self.move_down();
|
|
ViewAction::None
|
|
}
|
|
KeyCode::Enter => {
|
|
let provider = self.selected_provider();
|
|
if self.selected_has_key() {
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider })
|
|
} else if provider == ApiProvider::Moonshot && kimi_cli_credentials_present() {
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerKimiOAuthEnabled {
|
|
provider,
|
|
})
|
|
} else {
|
|
self.enter_key_entry();
|
|
ViewAction::None
|
|
}
|
|
}
|
|
KeyCode::Char(c) if key.modifiers.is_empty() && c.eq_ignore_ascii_case(&'r') => {
|
|
self.enter_key_entry();
|
|
ViewAction::None
|
|
}
|
|
_ => ViewAction::None,
|
|
},
|
|
Stage::KeyEntry => match key.code {
|
|
KeyCode::Esc => {
|
|
self.stage = Stage::List;
|
|
self.api_key_input.clear();
|
|
ViewAction::None
|
|
}
|
|
KeyCode::Backspace => {
|
|
self.api_key_input.pop();
|
|
ViewAction::None
|
|
}
|
|
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
self.api_key_input.pop();
|
|
ViewAction::None
|
|
}
|
|
KeyCode::Enter => {
|
|
let key = self.api_key_input.trim().to_string();
|
|
if key.is_empty() {
|
|
// Stay in key-entry; the user can press Esc to abort.
|
|
ViewAction::None
|
|
} else {
|
|
let provider = self.selected_provider();
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
|
|
provider,
|
|
api_key: key,
|
|
})
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
// Reject ASCII whitespace so a stray space/tab doesn't slip
|
|
// into a credential; bracketed paste happens via the input
|
|
// path that already trims on submit.
|
|
if !c.is_whitespace() {
|
|
self.api_key_input.push(c);
|
|
}
|
|
ViewAction::None
|
|
}
|
|
_ => ViewAction::None,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
|
|
let popup_height = match self.stage {
|
|
Stage::List => (self.providers.len() as u16).saturating_add(2),
|
|
Stage::KeyEntry => 10,
|
|
}
|
|
.min(area.height.saturating_sub(4))
|
|
.max(8);
|
|
let popup_area = Rect {
|
|
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
|
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
|
|
width: popup_width,
|
|
height: popup_height,
|
|
};
|
|
|
|
Clear.render(popup_area, buf);
|
|
|
|
match self.stage {
|
|
Stage::List => self.render_list(popup_area, buf),
|
|
Stage::KeyEntry => self.render_key_entry(popup_area, buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crossterm::event::{KeyEvent, KeyModifiers};
|
|
|
|
fn key(code: KeyCode) -> KeyEvent {
|
|
KeyEvent::new(code, KeyModifiers::NONE)
|
|
}
|
|
|
|
fn move_to_provider(picker: &mut ProviderPickerView, provider: ApiProvider) {
|
|
let max_steps = picker.providers.len();
|
|
for _ in 0..max_steps {
|
|
if picker.selected_provider() == provider {
|
|
return;
|
|
}
|
|
picker.handle_key(key(KeyCode::Down));
|
|
}
|
|
panic!("provider {provider:?} not found in picker");
|
|
}
|
|
|
|
fn render_text(picker: &ProviderPickerView, width: u16, height: u16) -> String {
|
|
let area = Rect::new(0, 0, width, height);
|
|
let mut buf = Buffer::empty(area);
|
|
picker.render(area, &mut buf);
|
|
(0..height)
|
|
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[test]
|
|
fn picker_lists_all_providers() {
|
|
let config = Config::default();
|
|
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
let names: Vec<_> = picker
|
|
.providers
|
|
.iter()
|
|
.map(|(p, _)| p.display_name())
|
|
.collect();
|
|
assert_eq!(
|
|
names,
|
|
vec![
|
|
"DeepSeek",
|
|
"NVIDIA NIM",
|
|
"OpenAI-compatible",
|
|
"AtlasCloud",
|
|
"Wanjie Ark",
|
|
"Volcengine Ark",
|
|
"OpenRouter",
|
|
"Xiaomi MiMo",
|
|
"Novita AI",
|
|
"Fireworks AI",
|
|
"SiliconFlow",
|
|
"Arcee AI",
|
|
"SiliconFlow (China)",
|
|
"Moonshot/Kimi",
|
|
"SGLang",
|
|
"vLLM",
|
|
"Ollama",
|
|
"Hugging Face",
|
|
"Together AI",
|
|
"OpenAI Codex (ChatGPT)",
|
|
"Anthropic",
|
|
"Z.ai (GLM Coding)",
|
|
"StepFun / StepFlash"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ollama_is_selectable_without_key() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
move_to_provider(&mut picker, ApiProvider::Ollama);
|
|
assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
|
|
assert!(picker.selected_has_key());
|
|
let action = picker.handle_key(key(KeyCode::Enter));
|
|
match action {
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) => {
|
|
assert_eq!(provider, ApiProvider::Ollama);
|
|
}
|
|
other => panic!("expected ProviderPickerApplied, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn picker_marks_active_provider_as_initial_selection() {
|
|
let config = Config::default();
|
|
let picker = ProviderPickerView::new(ApiProvider::Openrouter, &config);
|
|
assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
|
|
assert_eq!(picker.active_provider, ApiProvider::Openrouter);
|
|
}
|
|
|
|
#[test]
|
|
fn list_navigation_wraps_between_first_and_last_provider() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
|
|
picker.handle_key(key(KeyCode::Up));
|
|
assert_eq!(picker.selected_provider(), ApiProvider::Stepfun);
|
|
|
|
picker.handle_key(key(KeyCode::Down));
|
|
assert_eq!(picker.selected_provider(), ApiProvider::Deepseek);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_no_key_transitions_to_key_entry_stage() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
// Move to OpenRouter, which has no key in default config.
|
|
move_to_provider(&mut picker, ApiProvider::Openrouter);
|
|
assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
|
|
let action = picker.handle_key(key(KeyCode::Enter));
|
|
assert!(matches!(action, ViewAction::None));
|
|
assert_eq!(picker.stage, Stage::KeyEntry);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_with_existing_key_emits_apply_and_closes() {
|
|
let config = Config {
|
|
api_key: Some("existing-deepseek-key".to_string()),
|
|
..Config::default()
|
|
};
|
|
let mut picker = ProviderPickerView::new(ApiProvider::NvidiaNim, &config);
|
|
// Move up once to DeepSeek (index 0), which has a key from the config.
|
|
picker.handle_key(key(KeyCode::Up));
|
|
let action = picker.handle_key(key(KeyCode::Enter));
|
|
match action {
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { provider }) => {
|
|
assert_eq!(provider, ApiProvider::Deepseek);
|
|
}
|
|
other => panic!("expected ProviderPickerApplied, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_can_reenter_key_entry_with_r() {
|
|
let config = Config {
|
|
providers: Some(crate::config::ProvidersConfig {
|
|
xiaomi_mimo: crate::config::ProviderConfig {
|
|
api_key: Some("mimo-key".to_string()),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}),
|
|
..Config::default()
|
|
};
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
move_to_provider(&mut picker, ApiProvider::XiaomiMimo);
|
|
|
|
let action = picker.handle_key(key(KeyCode::Char('r')));
|
|
|
|
assert!(matches!(action, ViewAction::None));
|
|
assert_eq!(picker.stage, Stage::KeyEntry);
|
|
assert!(picker.api_key_input.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_r_does_not_trigger_key_entry() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
|
|
let action = picker.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
|
|
|
assert!(matches!(action, ViewAction::None));
|
|
assert_eq!(picker.stage, Stage::List);
|
|
}
|
|
|
|
#[test]
|
|
fn configured_provider_footer_mentions_edit_key() {
|
|
let config = Config {
|
|
api_key: Some("existing-deepseek-key".to_string()),
|
|
..Config::default()
|
|
};
|
|
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
|
|
let rendered = render_text(&picker, 80, 12);
|
|
|
|
assert!(rendered.contains("Enter"));
|
|
assert!(rendered.contains("apply"));
|
|
assert!(rendered.contains("edit key"));
|
|
}
|
|
|
|
#[test]
|
|
fn key_entry_enter_submits_after_typing() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
// Navigate to Novita and trigger key entry.
|
|
move_to_provider(&mut picker, ApiProvider::Novita);
|
|
picker.handle_key(key(KeyCode::Enter));
|
|
assert_eq!(picker.stage, Stage::KeyEntry);
|
|
for c in "novita-key".chars() {
|
|
picker.handle_key(key(KeyCode::Char(c)));
|
|
}
|
|
let action = picker.handle_key(key(KeyCode::Enter));
|
|
match action {
|
|
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
|
|
provider,
|
|
api_key,
|
|
}) => {
|
|
assert_eq!(provider, ApiProvider::Novita);
|
|
assert_eq!(api_key, "novita-key");
|
|
}
|
|
other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn key_entry_esc_returns_to_list_without_emitting() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
move_to_provider(&mut picker, ApiProvider::Openrouter);
|
|
picker.handle_key(key(KeyCode::Enter));
|
|
assert_eq!(picker.stage, Stage::KeyEntry);
|
|
picker.handle_key(key(KeyCode::Char('a')));
|
|
let action = picker.handle_key(key(KeyCode::Esc));
|
|
assert!(matches!(action, ViewAction::None));
|
|
assert_eq!(picker.stage, Stage::List);
|
|
assert!(picker.api_key_input.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn list_esc_closes_without_emitting() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
let action = picker.handle_key(key(KeyCode::Esc));
|
|
assert!(matches!(action, ViewAction::Close));
|
|
}
|
|
|
|
#[test]
|
|
fn key_entry_strips_whitespace_chars() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
move_to_provider(&mut picker, ApiProvider::Openrouter);
|
|
picker.handle_key(key(KeyCode::Enter));
|
|
assert_eq!(picker.stage, Stage::KeyEntry);
|
|
for c in "abc def".chars() {
|
|
picker.handle_key(key(KeyCode::Char(c)));
|
|
}
|
|
assert_eq!(picker.api_key_input, "abcdef");
|
|
}
|
|
|
|
#[test]
|
|
fn small_list_render_keeps_selected_provider_visible_after_down_navigation() {
|
|
let config = Config::default();
|
|
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
move_to_provider(&mut picker, ApiProvider::Ollama);
|
|
|
|
let rendered = render_text(&picker, 80, 12);
|
|
|
|
assert!(rendered.contains("Ollama"));
|
|
assert!(!rendered.contains("DeepSeek *"));
|
|
}
|
|
|
|
#[test]
|
|
fn small_list_render_keeps_initial_active_provider_visible() {
|
|
let config = Config::default();
|
|
let picker = ProviderPickerView::new(ApiProvider::Ollama, &config);
|
|
|
|
let rendered = render_text(&picker, 80, 12);
|
|
|
|
assert!(rendered.contains("Ollama *"));
|
|
}
|
|
|
|
#[test]
|
|
fn tall_list_render_shows_all_providers_without_scrolling() {
|
|
let config = Config::default();
|
|
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
|
|
let rendered = render_text(&picker, 80, 23);
|
|
|
|
assert!(rendered.contains("DeepSeek *"));
|
|
assert!(rendered.contains("Ollama"));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_provider_row_uses_strong_highlight() {
|
|
let config = Config::default();
|
|
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
|
|
let area = Rect::new(0, 0, 80, 20);
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
picker.render(area, &mut buf);
|
|
|
|
let highlighted_cells = area
|
|
.positions()
|
|
.filter(|position| {
|
|
let cell = &buf[*position];
|
|
cell.bg == palette::SURFACE_ELEVATED
|
|
})
|
|
.count();
|
|
assert!(
|
|
highlighted_cells >= 32,
|
|
"selected provider row should use a visible continuous highlight"
|
|
);
|
|
}
|
|
}
|