use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget, Wrap},
};
use crate::config::{
ApiProvider, Config, base_url_uses_local_host, has_api_key_for, provider_is_configured,
};
use crate::core::ops::ProviderRuntimeStatus;
use crate::localization::{Locale, MessageId, tr};
use crate::model_profile::{
SupportState, resolved_capability_profile, resolved_capability_profile_for_route,
};
use crate::models_dev_live::{self, ModelsDevFreshness};
use crate::palette;
use crate::provider_lake::{catalog_model_count_for_provider, catalog_offering_for_model};
use crate::provider_readiness::{
CredentialState, ProviderReadinessSnapshot, ProviderRouteIdentity, ResolvedProviderReadiness,
credential_state_for_provider, route_identity_for_model,
};
use crate::tui::app::ReasoningEffort;
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
centered_modal_area, render_modal_footer, render_modal_surface,
};
use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot};
use codewhale_config::provider::{CredentialAcquisition, WireFormat};
use codewhale_config::route::{PricingSku, RequestProtocol};
use serde_json::Value;
use std::borrow::Cow;
use std::sync::OnceLock;
const DS4_PROVIDER_ID: &str = "ds4";
const DS4_BASE_URL: &str = "http://127.0.0.1:8000/v1";
const DS4_DEFAULT_MODEL: &str = "deepseek-v4-flash";
const LM_STUDIO_PROVIDER_ID: &str = "lm_studio";
const LM_STUDIO_BASE_URL: &str = "http://127.0.0.1:1234/v1";
const SENSENOVA_PROVIDER_ID: &str = "sensenova";
const SENSENOVA_BASE_URL: &str = "https://token.sensenova.cn/v1";
const SENSENOVA_DEFAULT_MODEL: &str = "deepseek-v4-flash";
const SENSENOVA_API_KEY_ENV: &str = "SENSENOVA_API_KEY";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stage {
List,
XaiAuthChoice,
KeyEntry,
ExternalConsentChoice,
ExternalConsentConfirm,
ModelPick,
PlanTier,
StepfunBillingRoute,
Confirm,
CustomForm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExternalConsentChoice {
Disabled,
ReadOnly,
ManagedUnavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum XaiAuthChoice {
ApiKey,
DeviceOAuth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KimiCodePlanTier {
Safe262k,
OneMillion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StepfunBillingRoute {
PayAsYouGo,
StepPlan,
}
impl StepfunBillingRoute {
fn base_url(self) -> &'static str {
match self {
Self::PayAsYouGo => crate::config::DEFAULT_STEPFUN_BASE_URL,
Self::StepPlan => crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL,
}
}
}
fn stepfun_route_is_selectable(provider: ApiProvider, base_url: &str) -> bool {
provider == ApiProvider::Stepfun
&& matches!(
crate::pricing::billing_surface_for_route(provider, Some(base_url)),
Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE)
| Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CustomProviderField {
Name,
BaseUrl,
Model,
ApiKeyEnv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderListView {
Configured,
Catalog,
Local,
}
pub struct ProviderPickerView {
rows: Vec<ProviderDashboardRow>,
selected_idx: usize,
stage: Stage,
view: ProviderListView,
setup_mode: bool,
query: String,
api_key_input: String,
key_entry_error: Option<String>,
locale: Locale,
xai_auth_choice: XaiAuthChoice,
external_consent_choice: ExternalConsentChoice,
pending_api_key: Option<String>,
model_options: Vec<String>,
model_selected_idx: usize,
selected_model: Option<String>,
selected_context_window: Option<u32>,
kimi_code_plan_tier: KimiCodePlanTier,
stepfun_billing_route: StepfunBillingRoute,
pending_base_url: Option<String>,
custom_provider_field: CustomProviderField,
custom_provider_id: String,
custom_provider_base_url: String,
custom_provider_model: String,
custom_provider_api_key_env: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderDashboardRow {
pub provider: ApiProvider,
pub provider_id: String,
pub display_name: String,
pub kind: String,
pub base_url: String,
pub auth_status: ProviderAuthStatus,
pub catalog_status: ProviderCatalogStatus,
pub supported_protocols: Vec<String>,
pub available_model_count: usize,
pub default_route: ProviderDefaultRoute,
pub request_concurrency: ProviderRequestConcurrencySummary,
pub usage_meter: String,
pub reasoning: ProviderReasoningSummary,
pub capabilities: ProviderCapabilityBadges,
pub model_origin: ProviderModelOrigin,
pub(crate) readiness: ResolvedProviderReadiness,
pub maturity: ProviderMaturity,
pub messages: Vec<String>,
external_credential_status: Option<codewhale_config::ExternalCredentialConsentStatus>,
pub is_active: bool,
has_key: bool,
credential_state: CredentialState,
route_identity: ProviderRouteIdentity,
route_ok: bool,
pub is_configured: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderAuthStatus {
Configured,
Missing,
NoAuth,
Optional,
OAuthReady,
OAuthConsented,
OAuthMissing,
ImportedTokenUnavailable,
Local,
Legacy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderCatalogStatus {
Bundled,
DefaultOnly,
Legacy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderDefaultRoute {
pub logical_model: String,
pub wire_model: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderRequestConcurrencySummary {
pub limit: Option<usize>,
pub active: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderMaturity {
Experimental,
Supported,
}
impl ProviderMaturity {
fn for_provider(provider: ApiProvider) -> Self {
match provider {
ApiProvider::OpenaiCodex => Self::Experimental,
_ => Self::Supported,
}
}
fn tag(self) -> Option<&'static str> {
match self {
Self::Experimental => Some("experimental"),
Self::Supported => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderModelOrigin {
Default,
Saved,
Custom,
}
impl ProviderModelOrigin {
fn for_provider(provider: ApiProvider, has_saved_model: bool) -> Self {
if has_saved_model {
Self::Saved
} else if provider == ApiProvider::Custom {
Self::Custom
} else {
Self::Default
}
}
fn label(self) -> &'static str {
match self {
Self::Default => "default",
Self::Saved => "saved",
Self::Custom => "custom",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderCapabilityBadges {
pub context_window: Option<u32>,
pub context_window_source: Option<String>,
pub max_output: Option<u32>,
pub tools: SupportState,
pub structured: SupportState,
pub streaming: SupportState,
pub cache: SupportState,
pub vision: SupportState,
}
impl ProviderCapabilityBadges {
fn for_route(provider: ApiProvider, wire_model: &str) -> Self {
let cap = catalog_offering_for_model(provider, wire_model).map_or_else(
|| resolved_capability_profile(provider, wire_model),
|offering| {
let route_offering = offering.to_offering();
resolved_capability_profile_for_route(
provider,
wire_model,
route_offering.capabilities,
route_offering.limits,
)
},
);
Self {
context_window: cap.context_window,
context_window_source: None,
max_output: cap.max_output,
tools: cap.native_tool_calls,
structured: cap.structured_output,
streaming: cap.streaming,
cache: cap.prompt_caching,
vision: cap.image_input,
}
}
fn unknown() -> Self {
Self {
context_window: None,
context_window_source: None,
max_output: None,
tools: SupportState::Unknown,
structured: SupportState::Unknown,
streaming: SupportState::Unknown,
cache: SupportState::Unknown,
vision: SupportState::Unknown,
}
}
fn label(&self) -> String {
format!(
"ctx:{}({}) out:{} tools:{} json:{} stream:{} cache:{} vision:{}",
humanize_token_count(self.context_window),
self.context_window_source.as_deref().unwrap_or("?"),
humanize_token_count(self.max_output),
support_glyph(self.tools),
support_glyph(self.structured),
support_glyph(self.streaming),
support_glyph(self.cache),
support_glyph(self.vision),
)
}
}
fn support_glyph(state: SupportState) -> &'static str {
match state {
SupportState::Supported => "y",
SupportState::Unsupported => "n",
SupportState::Unknown => "?",
}
}
fn humanize_token_count(value: Option<u32>) -> String {
match value {
None => "?".to_string(),
Some(v) if v >= 1_000_000 && v % 1_000_000 == 0 => format!("{}M", v / 1_000_000),
Some(v) if v >= 1_000_000 => format!("{:.1}M", f64::from(v) / 1_000_000.0),
Some(v) if v >= 1_000 => format!("{}K", v / 1_000),
Some(v) => v.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderReasoningSummary {
pub support: ProviderReasoningSupport,
pub controls: Vec<String>,
pub stream_visibility: ProviderReasoningStreamVisibility,
pub selected_control: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderReasoningSupport {
Supported,
Unsupported,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderReasoningStreamVisibility {
StructuredThinking,
InlineTags,
SummaryOnly,
NotExposed,
Unknown,
}
impl ProviderDashboardRow {
#[cfg(test)]
fn from_config(provider: ApiProvider, active: ApiProvider, config: &Config) -> Self {
Self::from_config_with_runtime_status(provider, active, config, None)
}
fn from_config_with_runtime_status(
provider: ApiProvider,
active: ApiProvider,
config: &Config,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Self {
Self::from_config_with_provider_id(
provider,
active,
config,
None,
config.provider.as_deref(),
runtime_status,
)
}
fn from_custom_config_with_runtime_status(
provider_id: &str,
active: ApiProvider,
config: &Config,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Self {
let mut scoped = config.clone();
scoped.provider = Some(provider_id.to_string());
Self::from_config_with_provider_id(
ApiProvider::Custom,
active,
&scoped,
Some(provider_id),
config.provider.as_deref(),
runtime_status,
)
}
fn from_config_with_provider_id(
provider: ApiProvider,
active: ApiProvider,
config: &Config,
provider_id_override: Option<&str>,
active_provider_id: Option<&str>,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Self {
let configured = config.provider_config_for(provider);
let configured_base_url = configured
.and_then(|entry| entry.base_url.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let uses_kimi_imported_token = provider == ApiProvider::Moonshot
&& configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token);
let configured_base_url = configured_base_url.or_else(|| {
uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string())
});
let explicitly_configured_model = configured
.and_then(|entry| entry.model.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let has_configured_model = explicitly_configured_model.is_some();
let configured_model = explicitly_configured_model.or_else(|| {
uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_MODEL.to_string())
});
let model_origin = ProviderModelOrigin::for_provider(provider, has_configured_model);
let has_key = if provider == ApiProvider::Custom {
custom_provider_has_auth(configured)
} else {
has_api_key_for(config, provider)
};
let credential_state = credential_state_for_provider(config, provider);
let auth_mode = config.auth_mode_for_provider(provider);
let no_auth = crate::config::auth_mode_disables_api_key(auth_mode.as_deref());
let api_key_required = crate::config::auth_mode_requires_api_key(auth_mode.as_deref());
let official_endpoint = !config.provider_uses_custom_endpoint(provider);
let auth_base_url = config.base_url_for_route(provider);
let xai_oauth_ready = provider == ApiProvider::Xai
&& official_endpoint
&& crate::xai_oauth::credentials_valid(config);
let auth_status = if credential_state == CredentialState::ExternalConsent {
ProviderAuthStatus::OAuthConsented
} else {
auth_status_for(
provider,
&auth_base_url,
has_key,
configured,
no_auth,
api_key_required,
official_endpoint,
xai_oauth_ready,
)
};
let usage_meter = if matches!(auth_status, ProviderAuthStatus::ImportedTokenUnavailable) {
"usage: Kimi API key required".to_string()
} else {
usage_meter_for(provider)
};
let provider_id = provider_id_override
.map(str::to_string)
.unwrap_or_else(|| provider.as_str().to_string());
let display_name = provider_id_override
.map(|id| format!("{id} (custom)"))
.unwrap_or_else(|| provider.display_name().to_string());
let is_active = if provider == ApiProvider::Custom {
active == ApiProvider::Custom
&& match provider_id_override {
Some(id) => active_provider_id == Some(id),
None => true,
}
} else {
provider == active
};
let request_concurrency =
ProviderRequestConcurrencySummary::for_row(provider, config, runtime_status, is_active);
let compatibility_kind = (provider == ApiProvider::DeepseekCN)
.then_some(codewhale_config::ProviderKind::Deepseek);
let Some(kind) = provider.kind().or(compatibility_kind) else {
return Self {
provider,
provider_id,
display_name,
kind: "legacy".to_string(),
base_url: configured_base_url
.unwrap_or_else(|| provider.default_base_url().to_string()),
auth_status: ProviderAuthStatus::Legacy,
catalog_status: ProviderCatalogStatus::Legacy,
supported_protocols: vec![protocol_label(WireFormat::ChatCompletions).to_string()],
available_model_count: 0,
default_route: ProviderDefaultRoute {
logical_model: configured_model
.clone()
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
wire_model: "legacy alias".to_string(),
},
request_concurrency,
usage_meter,
reasoning: ProviderReasoningSummary::unknown(provider, config),
capabilities: ProviderCapabilityBadges::unknown(),
model_origin,
readiness: ResolvedProviderReadiness::Legacy,
maturity: ProviderMaturity::for_provider(provider),
messages: vec![
"legacy DeepSeek China alias; routing maps through DeepSeek compatibility"
.to_string(),
],
external_credential_status: None,
is_active,
has_key,
credential_state: CredentialState::Legacy,
route_identity: route_identity_for_model(
config,
provider,
configured_model.as_deref().unwrap_or("deepseek-v4-pro"),
),
route_ok: true,
is_configured: provider_is_configured(
provider,
is_active,
has_key,
configured,
provider == ApiProvider::Custom && provider_id_override.is_some(),
),
};
};
let available_model_count = catalog_model_count_for_provider(provider);
let catalog_status = if available_model_count == 0 {
ProviderCatalogStatus::DefaultOnly
} else {
ProviderCatalogStatus::Bundled
};
let mut messages = Vec::new();
let route = crate::route_runtime::resolve_route_candidate_with_context_metadata(
provider,
configured_model.as_deref(),
None,
(provider != ApiProvider::DeepseekCN)
.then(|| configured_base_url.clone())
.flatten(),
config.context_window_for_provider_config(provider),
None,
);
let (
base_url,
supported_protocols,
default_route,
resolved_pricing,
route_ok,
route_context_window,
route_context_window_source,
) = match route {
Ok(resolution) => {
let candidate = resolution.candidate;
if !candidate.validation().messages.is_empty() {
messages.extend(candidate.validation().messages.clone());
}
(
if provider == ApiProvider::DeepseekCN {
configured_base_url
.clone()
.unwrap_or_else(|| provider.default_base_url().to_string())
} else {
candidate.endpoint().base_url.clone()
},
vec![protocol_label(candidate.protocol()).to_string()],
ProviderDefaultRoute {
logical_model: candidate.logical_model().raw().to_string(),
wire_model: candidate.wire_model_id().as_str().to_string(),
},
pricing_label(provider, candidate.pricing()),
candidate.validation().ok,
Some(resolution.context_window.tokens),
Some(resolution.context_window.source.label().to_string()),
)
}
Err(error) => {
messages.push(format!("route validation failed: {error}"));
(
configured_base_url.unwrap_or_else(|| provider.default_base_url().to_string()),
vec![
provider
.metadata()
.and_then(|metadata| metadata.wire_policy().fixed())
.map(|protocol| protocol_label(protocol).to_string())
.unwrap_or_else(|| "model-aware".to_string()),
],
ProviderDefaultRoute {
logical_model: configured_model.unwrap_or_else(|| "invalid".to_string()),
wire_model: "unresolved".to_string(),
},
usage_meter.clone(),
false,
None,
None,
)
}
};
let resolved_pricing =
if matches!(auth_status, ProviderAuthStatus::ImportedTokenUnavailable) {
usage_meter
} else if provider == ApiProvider::Ollama
&& !crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
&& resolved_pricing == "cost: local"
{
"cost: unknown".to_string()
} else {
resolved_pricing
};
if matches!(
auth_status,
ProviderAuthStatus::Missing
| ProviderAuthStatus::OAuthMissing
| ProviderAuthStatus::ImportedTokenUnavailable
) {
messages.push(missing_auth_message(provider, configured, &provider_id));
}
if catalog_status == ProviderCatalogStatus::DefaultOnly {
messages.push("catalog snapshot missing; using provider default".to_string());
}
let route_identity =
route_identity_for_model(config, provider, &default_route.logical_model);
let readiness = readiness_for(
&route_identity,
credential_state,
route_ok,
&ProviderReadinessSnapshot::default(),
);
let reasoning =
ProviderReasoningSummary::for_route(provider, &base_url, &default_route, config);
let mut capabilities =
ProviderCapabilityBadges::for_route(provider, &default_route.wire_model);
if let Some(context_window) = route_context_window {
capabilities.context_window = Some(context_window);
}
capabilities.context_window_source = route_context_window_source;
let external_credential_status = config.external_credential_consent_status(provider);
Self {
provider,
provider_id,
display_name,
kind: configured
.and_then(|entry| entry.kind.as_deref())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{kind:?}")),
base_url,
auth_status,
catalog_status,
supported_protocols,
available_model_count,
default_route,
request_concurrency,
usage_meter: resolved_pricing,
reasoning,
capabilities,
model_origin,
readiness,
maturity: ProviderMaturity::for_provider(provider),
messages,
external_credential_status,
is_active,
has_key,
credential_state,
route_identity,
route_ok,
is_configured: provider_is_configured(
provider,
is_active,
has_key,
configured,
provider == ApiProvider::Custom && provider_id_override.is_some(),
),
}
}
fn list_row_hint(&self, view: ProviderListView) -> String {
match view {
ProviderListView::Configured => {
format!("{} | {}", self.readiness.label(), self.auth_status.label())
}
ProviderListView::Catalog => self.compact_hint(),
ProviderListView::Local => format!(
"local · no cloud key · {} · {}",
compact_base_url(&self.base_url),
self.default_route.logical_model
),
}
}
fn compact_hint(&self) -> String {
let self_hosted =
if crate::config::provider_route_is_keyless_self_hosted(self.provider, &self.base_url)
|| matches!(
self.auth_status,
ProviderAuthStatus::Local | ProviderAuthStatus::Optional
)
{
" (self-hosted)"
} else {
""
};
let request_concurrency = self
.request_concurrency
.label()
.map(|label| format!(" | {label}"))
.unwrap_or_default();
format!(
"{} | {} | {} | {} | base:{}{} | route:{}{} origin:{} | {} | {}{} | catalog:{}{}",
self.readiness.label(),
self.auth_status.label(),
self.usage_meter,
self.supported_protocols.join("+"),
compact_base_url(&self.base_url),
self_hosted,
self.default_route.logical_model,
route_wire_suffix(&self.default_route),
self.model_origin.label(),
self.capabilities.label(),
self.reasoning.label(),
request_concurrency,
self.catalog_label(),
self.maturity
.tag()
.map(|tag| format!(" | {tag}"))
.unwrap_or_default(),
)
}
fn catalog_label(&self) -> String {
match self.catalog_status {
ProviderCatalogStatus::Bundled => format!("{} bundled", self.available_model_count),
ProviderCatalogStatus::DefaultOnly => "default-only".to_string(),
ProviderCatalogStatus::Legacy => "legacy".to_string(),
}
}
fn matches_query(&self, query: &str) -> bool {
let query = query.trim().to_ascii_lowercase();
if query.is_empty() {
return true;
}
self.display_name.to_ascii_lowercase().contains(&query)
|| self.provider_id.to_ascii_lowercase().contains(&query)
|| self.kind.to_ascii_lowercase().contains(&query)
|| self.base_url.to_ascii_lowercase().contains(&query)
|| self.provider.as_str().to_ascii_lowercase().contains(&query)
|| self
.default_route
.logical_model
.to_ascii_lowercase()
.contains(&query)
|| self
.default_route
.wire_model
.to_ascii_lowercase()
.contains(&query)
}
}
impl ProviderRequestConcurrencySummary {
fn for_row(
provider: ApiProvider,
config: &Config,
runtime_status: Option<&ProviderRuntimeStatus>,
is_active: bool,
) -> Self {
let mut summary = Self {
limit: config.provider_max_concurrency(provider),
active: None,
};
if is_active
&& let Some(status) = runtime_status
&& status.provider == provider
{
summary.limit = status.request_concurrency_limit;
summary.active = Some(status.active_provider_requests);
}
summary
}
fn label(self) -> Option<String> {
match (self.limit, self.active) {
(Some(limit), Some(active)) => Some(format!("req:{active}/{limit}")),
(Some(limit), None) => Some(format!("req:cap {limit}")),
(None, Some(active)) if active > 0 => Some(format!("req:{active}/uncapped")),
_ => None,
}
}
}
impl ProviderReasoningSummary {
fn for_route(
provider: ApiProvider,
base_url: &str,
route: &ProviderDefaultRoute,
config: &Config,
) -> Self {
if provider == ApiProvider::OpenaiCodex {
return Self {
support: ProviderReasoningSupport::Supported,
controls: codex_reasoning_controls(),
stream_visibility: ProviderReasoningStreamVisibility::StructuredThinking,
selected_control: selected_reasoning_control(provider, config),
};
}
if crate::config::is_exact_kimi_code_k3_route(provider, base_url, &route.wire_model) {
return Self {
support: ProviderReasoningSupport::Supported,
controls: vec!["low".to_string(), "high".to_string(), "max".to_string()],
stream_visibility: configured_or_default_stream_visibility(
provider,
config,
ProviderReasoningSupport::Supported,
),
selected_control: selected_reasoning_control(provider, config),
};
}
if let Some(offering) = reasoning_catalog_offering(provider, route) {
let support = match offering.reasoning {
Some(true) => ProviderReasoningSupport::Supported,
Some(false) => ProviderReasoningSupport::Unsupported,
None => ProviderReasoningSupport::Unknown,
};
let controls = reasoning_controls_from_options(&offering.reasoning_options);
return Self {
support,
controls,
stream_visibility: configured_or_default_stream_visibility(
provider, config, support,
),
selected_control: selected_reasoning_control(provider, config),
};
}
Self::unknown(provider, config)
}
fn unknown(provider: ApiProvider, config: &Config) -> Self {
Self {
support: ProviderReasoningSupport::Unknown,
controls: Vec::new(),
stream_visibility: configured_or_default_stream_visibility(
provider,
config,
ProviderReasoningSupport::Unknown,
),
selected_control: selected_reasoning_control(provider, config),
}
}
fn label(&self) -> String {
let support = match self.support {
ProviderReasoningSupport::Supported if !self.controls.is_empty() => {
format!("reasoning:{}", self.controls.join("/"))
}
ProviderReasoningSupport::Supported => "reasoning:yes".to_string(),
ProviderReasoningSupport::Unsupported => "reasoning:no".to_string(),
ProviderReasoningSupport::Unknown => "reasoning:unknown".to_string(),
};
let mut parts = vec![
support,
format!("stream:{}", self.stream_visibility.label()),
];
if let Some(selected) = &self.selected_control {
parts.push(format!("ctrl:{selected}"));
}
parts.join(" ")
}
}
impl ProviderReasoningStreamVisibility {
fn label(self) -> &'static str {
match self {
Self::StructuredThinking => "structured",
Self::InlineTags => "inline-tags",
Self::SummaryOnly => "summary-only",
Self::NotExposed => "not-exposed",
Self::Unknown => "unknown",
}
}
}
impl ProviderAuthStatus {
fn label(self) -> &'static str {
match self {
Self::Configured => "key:configured",
Self::Missing => "key:not-set",
Self::NoAuth => "auth:none",
Self::Optional => "key:optional",
Self::OAuthReady => "auth:oauth-ready",
Self::OAuthConsented => "auth:oauth-consented-select-to-check",
Self::OAuthMissing => "auth:oauth-missing",
Self::ImportedTokenUnavailable => "auth:imported-token-unavailable",
Self::Local => "local",
Self::Legacy => "legacy",
}
}
}
fn catalog_freshness_title_suffix() -> &'static str {
catalog_freshness_title_suffix_for(models_dev_live::status().freshness)
}
fn catalog_freshness_title_suffix_for(freshness: ModelsDevFreshness) -> &'static str {
match freshness {
ModelsDevFreshness::Stale => " · stale",
ModelsDevFreshness::Failed => " · refresh failed; catalog available",
ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
}
}
fn reasoning_catalog_offering(
provider: ApiProvider,
route: &ProviderDefaultRoute,
) -> Option<&'static CatalogOffering> {
let provider_id = provider.kind()?.as_str();
bundled_reasoning_catalog()
.offerings
.iter()
.find(|offering| {
offering.provider == provider_id
&& offering
.wire_model_id
.eq_ignore_ascii_case(&route.wire_model)
})
}
fn bundled_reasoning_catalog() -> &'static CatalogSnapshot {
static CATALOG: OnceLock<CatalogSnapshot> = OnceLock::new();
CATALOG.get_or_init(|| CatalogSnapshot {
offerings: codewhale_config::catalog::bundled_catalog_offerings(),
})
}
fn codex_reasoning_controls() -> Vec<String> {
[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
]
.iter()
.map(|effort| {
effort
.display_label_for_provider(ApiProvider::OpenaiCodex)
.to_string()
})
.collect()
}
fn reasoning_controls_from_options(options: &[Value]) -> Vec<String> {
let mut controls = Vec::new();
for option in options {
collect_reasoning_controls(option, &mut controls);
}
controls
}
fn collect_reasoning_controls(value: &Value, controls: &mut Vec<String>) {
match value {
Value::String(text) => push_reasoning_control(controls, text),
Value::Array(items) => {
for item in items {
collect_reasoning_controls(item, controls);
}
}
Value::Object(map) => {
if let Some(values) = map.get("values") {
collect_reasoning_controls(values, controls);
}
}
_ => {}
}
}
fn push_reasoning_control(controls: &mut Vec<String>, value: &str) {
let normalized = value.trim();
if normalized.is_empty() || controls.iter().any(|item| item == normalized) {
return;
}
controls.push(normalized.to_string());
}
fn selected_reasoning_control(provider: ApiProvider, config: &Config) -> Option<String> {
let effort = ReasoningEffort::from_setting_for_provider(config.reasoning_effort()?, provider);
Some(effort.display_label_for_provider(provider).to_string())
}
fn configured_or_default_stream_visibility(
provider: ApiProvider,
config: &Config,
support: ProviderReasoningSupport,
) -> ProviderReasoningStreamVisibility {
if let Some(configured) = config
.provider_config_for(provider)
.and_then(|entry| entry.reasoning_stream_style.as_deref())
&& let Some(visibility) = parse_reasoning_stream_visibility(configured)
{
return visibility;
}
match support {
ProviderReasoningSupport::Unsupported => ProviderReasoningStreamVisibility::NotExposed,
ProviderReasoningSupport::Unknown => ProviderReasoningStreamVisibility::Unknown,
ProviderReasoningSupport::Supported => default_reasoning_stream_visibility(provider),
}
}
fn parse_reasoning_stream_visibility(value: &str) -> Option<ProviderReasoningStreamVisibility> {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"separate_field" | "separate" | "field" | "structured" | "structured_thinking" => {
Some(ProviderReasoningStreamVisibility::StructuredThinking)
}
"inline_tags" | "inline" | "think_tags" | "thinking_tags" => {
Some(ProviderReasoningStreamVisibility::InlineTags)
}
"summary" | "summary_only" => Some(ProviderReasoningStreamVisibility::SummaryOnly),
"none" | "text" | "disabled" | "off" | "not_exposed" => {
Some(ProviderReasoningStreamVisibility::NotExposed)
}
_ => None,
}
}
fn default_reasoning_stream_visibility(provider: ApiProvider) -> ProviderReasoningStreamVisibility {
match provider {
ApiProvider::OpenaiCodex
| ApiProvider::Deepseek
| ApiProvider::DeepseekCN
| ApiProvider::NvidiaNim
| ApiProvider::Openrouter
| ApiProvider::XiaomiMimo
| ApiProvider::Novita
| ApiProvider::Fireworks
| ApiProvider::Siliconflow
| ApiProvider::SiliconflowCn
| ApiProvider::Volcengine
| ApiProvider::Arcee
| ApiProvider::Minimax
| ApiProvider::MinimaxAnthropic
| ApiProvider::Sglang
| ApiProvider::Vllm
| ApiProvider::Zai
| ApiProvider::Xai
| ApiProvider::ModelstudioTokenPlan
| ApiProvider::ModelstudioTokenPlanAnthropic
| ApiProvider::ModelstudioCodingPlan
| ApiProvider::ModelstudioCodingPlanAnthropic
| ApiProvider::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking,
_ => ProviderReasoningStreamVisibility::Unknown,
}
}
#[allow(clippy::too_many_arguments)]
fn auth_status_for(
provider: ApiProvider,
base_url: &str,
has_key: bool,
configured: Option<&crate::config::ProviderConfig>,
no_auth: bool,
api_key_required: bool,
official_endpoint: bool,
xai_oauth_ready: bool,
) -> ProviderAuthStatus {
if no_auth {
return ProviderAuthStatus::NoAuth;
}
if crate::config::provider_route_is_keyless_self_hosted(provider, base_url) {
if api_key_required {
return if has_key {
ProviderAuthStatus::Configured
} else {
ProviderAuthStatus::Missing
};
}
if provider == ApiProvider::Ollama {
return ProviderAuthStatus::Local;
}
return if has_explicit_credential(provider, configured) {
ProviderAuthStatus::Configured
} else {
ProviderAuthStatus::Optional
};
}
if provider == ApiProvider::Custom {
return if custom_provider_auth_is_optional(configured) {
ProviderAuthStatus::Optional
} else if has_key {
ProviderAuthStatus::Configured
} else {
ProviderAuthStatus::Missing
};
}
if provider == ApiProvider::Moonshot
&& official_endpoint
&& configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
{
return ProviderAuthStatus::ImportedTokenUnavailable;
}
if provider == ApiProvider::OpenaiCodex && official_endpoint {
return if has_key {
ProviderAuthStatus::OAuthReady
} else {
ProviderAuthStatus::OAuthMissing
};
}
if provider == ApiProvider::Xai
&& official_endpoint
&& let Some(status) = xai_oauth_status(configured, xai_oauth_ready)
{
return status;
}
if has_key {
ProviderAuthStatus::Configured
} else {
ProviderAuthStatus::Missing
}
}
fn xai_oauth_status(
configured: Option<&crate::config::ProviderConfig>,
oauth_credentials_present: bool,
) -> Option<ProviderAuthStatus> {
let oauth_selected = configured
.and_then(|entry| entry.auth_mode.as_deref())
.is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth);
if !oauth_selected {
return None;
}
Some(if oauth_credentials_present {
ProviderAuthStatus::OAuthReady
} else if has_explicit_credential(ApiProvider::Xai, configured) {
ProviderAuthStatus::Configured
} else {
ProviderAuthStatus::OAuthMissing
})
}
fn has_explicit_credential(
provider: ApiProvider,
configured: Option<&crate::config::ProviderConfig>,
) -> bool {
provider
.env_vars()
.iter()
.any(|var| std::env::var(var).is_ok_and(|value| !value.trim().is_empty()))
|| configured.is_some_and(|entry| {
entry.api_key.as_deref().is_some_and(|value| {
crate::config::classify_config_api_key_value(value)
== crate::config::ConfigApiKeyValueKind::Literal
})
})
}
fn custom_provider_has_auth(configured: Option<&crate::config::ProviderConfig>) -> bool {
if custom_provider_auth_is_optional(configured) {
return true;
}
configured.is_some_and(|entry| {
entry.api_key.as_deref().is_some_and(|value| {
crate::config::classify_config_api_key_value(value)
== crate::config::ConfigApiKeyValueKind::Literal
}) || entry
.api_key_env
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
})
}
fn custom_provider_auth_is_optional(configured: Option<&crate::config::ProviderConfig>) -> bool {
configured.is_some_and(|entry| {
entry
.auth_mode
.as_deref()
.is_some_and(|mode| crate::config::auth_mode_disables_api_key(Some(mode)))
|| entry
.base_url
.as_deref()
.is_some_and(base_url_uses_local_host)
})
}
fn missing_auth_message(
provider: ApiProvider,
configured: Option<&crate::config::ProviderConfig>,
provider_id: &str,
) -> String {
if provider == ApiProvider::Moonshot
&& configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
{
return "Kimi OAuth is unavailable; configure a Kimi API key".to_string();
}
if provider == ApiProvider::Custom {
if let Some(env_name) = configured
.and_then(|entry| entry.api_key_env.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
{
return format!("missing {env_name} for custom provider {provider_id}");
}
return format!("missing custom provider auth for {provider_id}");
}
format!("missing {}", provider.env_vars_label())
}
fn readiness_for(
identity: &ProviderRouteIdentity,
credential: CredentialState,
route_ok: bool,
health: &ProviderReadinessSnapshot,
) -> ResolvedProviderReadiness {
crate::provider_readiness::resolve_with_identity(identity, credential, route_ok, health)
}
fn usage_meter_for(provider: ApiProvider) -> String {
match provider {
ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => "cost: local".to_string(),
ApiProvider::OpenaiCodex => "usage: Codex OAuth quota".to_string(),
ApiProvider::XiaomiMimo => "cost: token-plan".to_string(),
ApiProvider::OpencodeGo => "usage: OpenCode Go subscription".to_string(),
ApiProvider::OpencodeZen => "cost: OpenCode Zen pay-as-you-go".to_string(),
_ => "cost: unknown".to_string(),
}
}
fn pricing_label(provider: ApiProvider, pricing: Option<&PricingSku>) -> String {
if provider == ApiProvider::OpencodeGo {
return usage_meter_for(provider);
}
match pricing {
Some(PricingSku::Token {
input_per_mtok,
output_per_mtok,
}) => match (input_per_mtok, output_per_mtok) {
(Some(input), Some(output)) => format!("cost: ${input:.2}/${output:.2} mtok"),
_ => "cost: token".to_string(),
},
Some(PricingSku::SubscriptionQuota { used_pct, .. }) => used_pct.map_or_else(
|| "usage: subscription quota".to_string(),
|pct| format!("usage: subscription {pct:.0}%"),
),
Some(PricingSku::AccountCredits { balance }) => balance.map_or_else(
|| "usage: account credits".to_string(),
|balance| format!("usage: ${balance:.2} credits"),
),
Some(PricingSku::LocalOrNotApplicable) => "cost: local".to_string(),
Some(PricingSku::UnknownOrStale) | None => usage_meter_for(provider),
}
}
fn protocol_label(protocol: RequestProtocol) -> &'static str {
match protocol {
WireFormat::ChatCompletions => "chat",
WireFormat::Responses => "responses",
WireFormat::AnthropicMessages => "anthropic",
}
}
fn route_wire_suffix(route: &ProviderDefaultRoute) -> String {
if route.logical_model == route.wire_model {
String::new()
} else {
format!(" -> {}", route.wire_model)
}
}
fn compact_base_url(base_url: &str) -> String {
let stripped = base_url
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
crate::tui::ui_text::truncate_line_to_width(stripped, 24)
}
#[must_use]
pub(crate) fn external_consent_target_for_provider(
provider: ApiProvider,
) -> Option<(
codewhale_config::ProviderKind,
codewhale_config::ExternalCredentialSource,
std::path::PathBuf,
)> {
let (consent_provider, source, path) = match provider {
ApiProvider::OpenaiCodex => (
codewhale_config::ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
crate::oauth::auth_file_path(),
),
ApiProvider::Xai => (
codewhale_config::ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
crate::xai_oauth::auth_file_path(),
),
_ => return None,
};
let path = codewhale_config::resolve_external_credential_path(path).ok()?;
Some((consent_provider, source, path))
}
pub(crate) fn external_consent_target_is_grantable(provider: ApiProvider) -> bool {
let Some((_, _, path)) = external_consent_target_for_provider(provider) else {
return false;
};
match provider {
ApiProvider::Xai => crate::xai_oauth::external_file_is_fresh(&path),
ApiProvider::OpenaiCodex => codex_external_file_is_fresh(&path),
_ => false,
}
}
fn codex_external_file_is_fresh(path: &std::path::Path) -> bool {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return false,
};
let value: Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return false,
};
let token = value
.get("tokens")
.and_then(|t| t.get("access_token"))
.and_then(|v| v.as_str())
.filter(|t| !t.trim().is_empty());
let Some(token) = token else {
return false;
};
if let Some(exp) = codex_jwt_expiry(token) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
return now + 60 < exp;
}
false
}
fn codex_jwt_expiry(token: &str) -> Option<u64> {
use base64::Engine as _;
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
let claims: Value = serde_json::from_slice(&decoded).ok()?;
claims.get("exp")?.as_u64()
}
impl ProviderPickerView {
fn key_entry_is_oauth_locked(&self) -> bool {
self.selected_provider().credential_help().acquisition == CredentialAcquisition::OAuth
}
#[cfg(test)]
#[must_use]
pub fn new(active: ApiProvider, config: &Config) -> Self {
Self::new_with_runtime_status(active, config, None)
}
#[must_use]
pub fn new_with_runtime_status(
active: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
Self::new_with_runtime_status_and_memory(active, config, runtime_status, None)
}
#[must_use]
pub fn new_with_runtime_status_and_memory(
active: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
memory: Option<&crate::tui::app::ProviderPickerMemory>,
) -> Self {
let runtime_status = runtime_status.as_ref();
let custom_rows = custom_provider_dashboard_rows(active, config, runtime_status);
let catalog_active = active.catalog_identity();
let mut rows: Vec<ProviderDashboardRow> = ApiProvider::catalog()
.iter()
.copied()
.filter(|provider| *provider != ApiProvider::Custom || custom_rows.is_empty())
.map(|p| {
ProviderDashboardRow::from_config_with_runtime_status(
p,
catalog_active,
config,
runtime_status,
)
})
.collect();
rows.extend(custom_rows);
rows.sort_by(|a, b| {
a.display_name
.to_ascii_lowercase()
.cmp(&b.display_name.to_ascii_lowercase())
.then_with(|| a.provider_id.cmp(&b.provider_id))
});
let selected_idx = rows
.iter()
.position(|row| row.is_active)
.or_else(|| rows.iter().position(|row| row.provider == active))
.unwrap_or(0);
let view = if rows.iter().any(|row| row.is_configured) {
ProviderListView::Configured
} else {
ProviderListView::Catalog
};
let mut picker = Self {
rows,
selected_idx,
stage: Stage::List,
view,
setup_mode: false,
query: String::new(),
api_key_input: String::new(),
key_entry_error: None,
locale: Locale::En,
xai_auth_choice: XaiAuthChoice::ApiKey,
external_consent_choice: ExternalConsentChoice::Disabled,
pending_api_key: None,
model_options: Vec::new(),
model_selected_idx: 0,
selected_model: None,
selected_context_window: None,
kimi_code_plan_tier: KimiCodePlanTier::Safe262k,
stepfun_billing_route: StepfunBillingRoute::PayAsYouGo,
pending_base_url: None,
custom_provider_field: CustomProviderField::Name,
custom_provider_id: String::new(),
custom_provider_base_url: String::new(),
custom_provider_model: String::new(),
custom_provider_api_key_env: String::new(),
};
picker.restore_memory(memory);
picker
}
#[must_use]
pub(crate) fn with_locale(mut self, locale: Locale) -> Self {
self.locale = locale;
self
}
fn tr(&self, id: MessageId) -> Cow<'static, str> {
tr(self.locale, id)
}
#[must_use]
pub(crate) fn with_provider_health(mut self, health: &ProviderReadinessSnapshot) -> Self {
for row in &mut self.rows {
row.readiness = readiness_for(
&row.route_identity,
row.credential_state,
row.route_ok,
health,
);
if let Some(detail) = row.readiness.detail()
&& !row.messages.iter().any(|message| message == detail)
{
row.messages.push(detail.to_string());
}
}
self
}
fn restore_memory(&mut self, memory: Option<&crate::tui::app::ProviderPickerMemory>) {
let Some(memory) = memory else {
return;
};
if memory.catalog_view {
self.view = ProviderListView::Catalog;
}
if let Some(remembered_id) = memory.selected_provider_id.as_deref()
&& let Some(idx) = self
.rows
.iter()
.position(|row| row.provider_id == remembered_id)
&& (self.row_visible(idx) || memory.catalog_view)
{
if memory.catalog_view {
self.view = ProviderListView::Catalog;
}
self.selected_idx = idx;
}
if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
self.selected_idx = (0..self.rows.len())
.find(|idx| self.row_visible(*idx))
.unwrap_or(0);
}
}
#[must_use]
pub fn new_for_setup(
active: ApiProvider,
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
Self::new_for_setup_inner(active, target, config, runtime_status, true)
}
#[must_use]
pub fn new_for_ds4_setup(
active: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
picker.setup_mode = true;
picker.enter_ds4_form();
picker
}
#[must_use]
pub fn new_for_onboarding(
active: ApiProvider,
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Self {
Self::new_for_setup_inner(active, target, config, runtime_status, false)
}
fn new_for_setup_inner(
active: ApiProvider,
target: Option<ApiProvider>,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
key_entry_for_missing_auth: bool,
) -> Self {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
picker.view = if !key_entry_for_missing_auth && target.is_none() {
ProviderListView::Local
} else {
ProviderListView::Catalog
};
picker.setup_mode = true;
if let Some(target) = target
&& let Some(idx) = picker.rows.iter().position(|row| row.provider == target)
{
picker.selected_idx = idx;
if key_entry_for_missing_auth && !picker.selected_has_key() {
picker.begin_setup();
}
} else if picker.view == ProviderListView::Local
&& let Some(idx) = picker
.rows
.iter()
.position(|row| row.provider == ApiProvider::Ollama)
{
picker.selected_idx = idx;
}
picker
}
#[must_use]
pub fn new_for_missing_auth(
active: ApiProvider,
target: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
) -> Option<Self> {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
let idx = picker.rows.iter().position(|row| row.provider == target)?;
picker.selected_idx = idx;
picker.view = ProviderListView::Catalog;
picker.begin_setup();
Some(picker)
}
fn row_visible(&self, idx: usize) -> bool {
let query = self.query.trim();
if !query.is_empty() {
return self.rows[idx].matches_query(query);
}
match self.view {
ProviderListView::Catalog => true,
ProviderListView::Configured => self.rows[idx].is_configured,
ProviderListView::Local => self.rows[idx].provider.is_self_hosted(),
}
}
fn visible_row_count(&self) -> usize {
(0..self.rows.len())
.filter(|idx| self.row_visible(*idx))
.count()
}
fn toggle_view(&mut self) {
self.view = match self.view {
ProviderListView::Configured => ProviderListView::Catalog,
ProviderListView::Catalog => ProviderListView::Configured,
ProviderListView::Local => ProviderListView::Catalog,
};
if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
self.selected_idx = (0..self.rows.len())
.find(|idx| self.row_visible(*idx))
.unwrap_or(0);
}
}
fn show_local_routes(&mut self) {
self.view = ProviderListView::Local;
self.query.clear();
if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
self.selected_idx = self
.rows
.iter()
.position(|row| row.provider == ApiProvider::Ollama)
.or_else(|| (0..self.rows.len()).find(|idx| self.row_visible(*idx)))
.unwrap_or(0);
}
}
fn update_query(&mut self, next: String) {
self.query = next;
self.selected_idx = (0..self.rows.len())
.find(|idx| self.row_visible(*idx))
.unwrap_or(0);
}
fn move_selection(&mut self, step: i64) {
let count = self.rows.len();
if count == 0 || self.visible_row_count() == 0 {
return;
}
let mut idx = self.selected_idx;
loop {
idx = ((idx as i64 + step).rem_euclid(count as i64)) as usize;
if self.row_visible(idx) {
self.selected_idx = idx;
return;
}
}
}
fn move_up(&mut self) {
self.move_selection(-1);
}
fn move_down(&mut self) {
self.move_selection(1);
}
fn selected_provider(&self) -> ApiProvider {
self.rows[self.selected_idx].provider
}
fn selected_provider_id(&self) -> Option<String> {
let row = &self.rows[self.selected_idx];
(row.provider == ApiProvider::Custom).then(|| row.provider_id.clone())
}
fn selected_has_key(&self) -> bool {
matches!(
self.rows[self.selected_idx].credential_state,
CredentialState::Saved
| CredentialState::ExternalConsent
| CredentialState::ImportedToken
| CredentialState::NoAuth
| CredentialState::Local
| CredentialState::Legacy
)
}
fn selected_route_is_valid(&self) -> bool {
self.rows[self.selected_idx].route_ok
}
fn enter_key_entry(&mut self) {
self.stage = Stage::KeyEntry;
self.api_key_input.clear();
self.key_entry_error = None;
self.pending_api_key = None;
self.pending_base_url = None;
self.model_options.clear();
self.model_selected_idx = 0;
self.selected_model = None;
}
fn begin_setup(&mut self) {
if self.selected_provider() == ApiProvider::Xai {
self.enter_xai_auth_choice();
} else if self.stepfun_billing_route_applies() {
self.enter_stepfun_billing_route();
} else {
self.enter_key_entry();
}
}
fn enter_xai_auth_choice(&mut self) {
self.xai_auth_choice = XaiAuthChoice::ApiKey;
self.stage = Stage::XaiAuthChoice;
self.api_key_input.clear();
self.key_entry_error = None;
self.pending_api_key = None;
}
fn move_xai_auth_choice(&mut self) {
self.xai_auth_choice = match self.xai_auth_choice {
XaiAuthChoice::ApiKey => XaiAuthChoice::DeviceOAuth,
XaiAuthChoice::DeviceOAuth => XaiAuthChoice::ApiKey,
};
}
fn stepfun_billing_route_applies(&self) -> bool {
self.rows
.get(self.selected_idx)
.is_some_and(|row| stepfun_route_is_selectable(row.provider, &row.base_url))
}
fn enter_stepfun_billing_route(&mut self) {
self.stepfun_billing_route = if crate::pricing::billing_surface_for_route(
ApiProvider::Stepfun,
Some(&self.rows[self.selected_idx].base_url),
) == Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
{
StepfunBillingRoute::StepPlan
} else {
StepfunBillingRoute::PayAsYouGo
};
self.stage = Stage::StepfunBillingRoute;
}
fn apply_stepfun_billing_route(&mut self) {
let base_url = self.stepfun_billing_route.base_url().to_string();
self.enter_key_entry();
self.rows[self.selected_idx].base_url.clone_from(&base_url);
self.pending_base_url = Some(base_url);
}
fn selected_external_consent_target(
&self,
) -> Option<(
codewhale_config::ProviderKind,
codewhale_config::ExternalCredentialSource,
std::path::PathBuf,
)> {
external_consent_target_for_provider(self.selected_provider())
}
fn enter_external_consent_choice(&mut self) {
if self.selected_external_consent_target().is_some() {
self.external_consent_choice = ExternalConsentChoice::Disabled;
self.stage = Stage::ExternalConsentChoice;
}
}
fn move_external_consent_choice(&mut self, delta: isize) {
let index = match self.external_consent_choice {
ExternalConsentChoice::Disabled => 0,
ExternalConsentChoice::ReadOnly => 1,
ExternalConsentChoice::ManagedUnavailable => 2,
};
self.external_consent_choice = match (index as isize + delta).rem_euclid(3) {
0 => ExternalConsentChoice::Disabled,
1 => ExternalConsentChoice::ReadOnly,
_ => ExternalConsentChoice::ManagedUnavailable,
};
}
fn build_external_consent_event(&self) -> Option<ViewEvent> {
let (provider, source, path) = self.selected_external_consent_target()?;
Some(ViewEvent::ProviderPickerExternalConsentConfirmed {
provider: self.selected_provider(),
consent_provider: provider,
source,
path,
})
}
#[must_use]
pub fn new_for_key_entry_with_error(
active: ApiProvider,
target: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
error: String,
) -> Option<Self> {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
let idx = picker.rows.iter().position(|row| row.provider == target)?;
picker.selected_idx = idx;
picker.view = ProviderListView::Catalog;
picker.stage = Stage::KeyEntry;
picker.key_entry_error = Some(error);
Some(picker)
}
#[must_use]
pub fn new_for_model_pick_after_validation(
active: ApiProvider,
target: ApiProvider,
config: &Config,
runtime_status: Option<ProviderRuntimeStatus>,
api_key: String,
base_url: Option<String>,
) -> Option<Self> {
let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
let idx = picker.rows.iter().position(|row| row.provider == target)?;
picker.selected_idx = idx;
picker.view = ProviderListView::Catalog;
picker.pending_api_key = Some(api_key);
if let Some(base_url) = base_url {
picker.rows[idx].base_url.clone_from(&base_url);
picker.pending_base_url = Some(base_url);
}
picker.api_key_input.clear();
picker.key_entry_error = None;
picker.enter_model_pick();
Some(picker)
}
fn enter_model_pick(&mut self) {
self.stage = Stage::ModelPick;
self.selected_context_window = None;
let provider = self.selected_provider();
let route = &self.rows[self.selected_idx].default_route;
let kimi_code_k3 = crate::config::is_exact_kimi_code_k3_route(
provider,
&self.rows[self.selected_idx].base_url,
&route.wire_model,
);
let preferred = if kimi_code_k3 {
route.wire_model.clone()
} else {
route.logical_model.clone()
};
let mut models = crate::provider_lake::all_catalog_models_for_provider(provider);
if kimi_code_k3
&& !preferred.trim().is_empty()
&& !models
.iter()
.any(|model| model.eq_ignore_ascii_case(preferred.trim()))
{
models.push(preferred.clone());
}
if models.is_empty() && !preferred.trim().is_empty() {
models.push(preferred.clone());
}
if models.is_empty() {
models.push(provider.as_str().to_string());
}
let selected = models
.iter()
.position(|model| model.eq_ignore_ascii_case(preferred.trim()))
.unwrap_or(0);
self.model_options = models;
self.model_selected_idx = selected.min(self.model_options.len().saturating_sub(1));
self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
}
fn enter_confirm(&mut self) {
if self.selected_model.is_none() {
self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
}
self.stage = Stage::Confirm;
}
fn selected_kimi_code_k3(&self) -> bool {
let Some(model) = self.selected_model.as_deref() else {
return false;
};
crate::config::is_exact_kimi_code_k3_route(
self.selected_provider(),
&self.rows[self.selected_idx].base_url,
model,
)
}
fn enter_plan_tier(&mut self) {
self.stage = Stage::PlanTier;
self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
}
fn apply_plan_tier(&mut self) {
self.selected_context_window = Some(match self.kimi_code_plan_tier {
KimiCodePlanTier::Safe262k => crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
KimiCodePlanTier::OneMillion => 1_048_576,
});
self.enter_confirm();
}
fn move_model_selection(&mut self, delta: isize) {
let len = self.model_options.len();
if len == 0 {
return;
}
let current = self.model_selected_idx as isize;
let next = (current + delta).rem_euclid(len as isize) as usize;
self.model_selected_idx = next;
self.selected_model = self.model_options.get(next).cloned();
}
fn build_setup_confirmed_event(&self) -> Option<ViewEvent> {
let api_key = self.pending_api_key.as_ref()?.trim();
if api_key.is_empty() {
return None;
}
let model = self
.selected_model
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())?;
Some(ViewEvent::ProviderPickerSetupConfirmed {
provider: self.selected_provider(),
provider_id: self.selected_provider_id(),
api_key: api_key.to_string(),
model: model.to_string(),
context_window: self.selected_context_window,
base_url: self.pending_base_url.clone(),
})
}
fn enter_custom_form(&mut self) {
self.stage = Stage::CustomForm;
self.custom_provider_field = CustomProviderField::Name;
self.custom_provider_id.clear();
self.custom_provider_base_url.clear();
self.custom_provider_model.clear();
self.custom_provider_api_key_env.clear();
}
fn enter_ds4_form(&mut self) {
self.stage = Stage::CustomForm;
self.custom_provider_field = CustomProviderField::ApiKeyEnv;
self.custom_provider_id = DS4_PROVIDER_ID.to_string();
self.custom_provider_base_url = DS4_BASE_URL.to_string();
self.custom_provider_model = DS4_DEFAULT_MODEL.to_string();
self.custom_provider_api_key_env.clear();
}
fn enter_lm_studio_form(&mut self) {
self.stage = Stage::CustomForm;
self.custom_provider_field = CustomProviderField::Model;
self.custom_provider_id = LM_STUDIO_PROVIDER_ID.to_string();
self.custom_provider_base_url = LM_STUDIO_BASE_URL.to_string();
self.custom_provider_model.clear();
self.custom_provider_api_key_env.clear();
}
fn enter_sensenova_form(&mut self) {
self.stage = Stage::CustomForm;
self.custom_provider_field = CustomProviderField::ApiKeyEnv;
self.custom_provider_id = SENSENOVA_PROVIDER_ID.to_string();
self.custom_provider_base_url = SENSENOVA_BASE_URL.to_string();
self.custom_provider_model = SENSENOVA_DEFAULT_MODEL.to_string();
self.custom_provider_api_key_env = SENSENOVA_API_KEY_ENV.to_string();
}
fn custom_form_field_mut(&mut self) -> &mut String {
match self.custom_provider_field {
CustomProviderField::Name => &mut self.custom_provider_id,
CustomProviderField::BaseUrl => &mut self.custom_provider_base_url,
CustomProviderField::Model => &mut self.custom_provider_model,
CustomProviderField::ApiKeyEnv => &mut self.custom_provider_api_key_env,
}
}
fn custom_form_field_value(&self, field: CustomProviderField) -> &str {
match field {
CustomProviderField::Name => &self.custom_provider_id,
CustomProviderField::BaseUrl => &self.custom_provider_base_url,
CustomProviderField::Model => &self.custom_provider_model,
CustomProviderField::ApiKeyEnv => &self.custom_provider_api_key_env,
}
}
fn advance_custom_field(&mut self) {
self.custom_provider_field = match self.custom_provider_field {
CustomProviderField::Name => CustomProviderField::BaseUrl,
CustomProviderField::BaseUrl => CustomProviderField::Model,
CustomProviderField::Model => CustomProviderField::ApiKeyEnv,
CustomProviderField::ApiKeyEnv => CustomProviderField::ApiKeyEnv,
};
}
fn retreat_custom_field(&mut self) {
self.custom_provider_field = match self.custom_provider_field {
CustomProviderField::Name => CustomProviderField::Name,
CustomProviderField::BaseUrl => CustomProviderField::Name,
CustomProviderField::Model => CustomProviderField::BaseUrl,
CustomProviderField::ApiKeyEnv => CustomProviderField::Model,
};
}
fn build_custom_provider_event(&self) -> Option<ViewEvent> {
let provider_id = self.custom_provider_id.trim();
let base_url = self.custom_provider_base_url.trim();
if provider_id.is_empty() || base_url.is_empty() {
return None;
}
let model = non_empty_string(&self.custom_provider_model);
let api_key_env = non_empty_string(&self.custom_provider_api_key_env);
Some(ViewEvent::ProviderPickerCustomProviderSubmitted {
provider_id: provider_id.to_string(),
base_url: base_url.to_string(),
model,
api_key_env,
})
}
fn env_var_for(provider: ApiProvider) -> String {
provider.env_vars_label()
}
fn env_var_for_selected_row(&self) -> String {
let row = &self.rows[self.selected_idx];
if row.provider == ApiProvider::Custom {
return row
.messages
.iter()
.find_map(|message| {
message
.strip_prefix("missing ")
.and_then(|rest| rest.split_once(" for custom provider"))
.map(|(env_name, _)| env_name.to_string())
})
.unwrap_or_else(|| format!("[providers.{}] api_key", row.provider_id));
}
Self::env_var_for(row.provider)
}
fn filtered_rows(&self) -> Vec<(usize, &ProviderDashboardRow)> {
self.rows
.iter()
.enumerate()
.filter(|(idx, _)| self.row_visible(*idx))
.collect()
}
fn visible_start(selected_pos: usize, total: usize, visible_rows: usize) -> usize {
if visible_rows == 0 {
return 0;
}
let max_start = total.saturating_sub(visible_rows);
selected_pos
.saturating_add(1)
.saturating_sub(visible_rows)
.min(max_start)
}
fn render_list(&self, area: Rect, buf: &mut Buffer) {
let enter_action = if !self.selected_route_is_valid() {
self.tr(MessageId::PickerActionUnavailable)
} else if self.selected_has_key() {
self.tr(MessageId::PickerActionApply)
} else {
self.tr(MessageId::PickerActionSetKey)
};
let title = match (self.setup_mode, self.view) {
(true, ProviderListView::Configured) => {
format!(" Provider setup{} ", catalog_freshness_title_suffix())
}
(true, ProviderListView::Catalog) => {
format!(" Provider setup · all{} ", catalog_freshness_title_suffix())
}
(true, ProviderListView::Local) => " Local models · no cloud key ".to_string(),
(false, ProviderListView::Configured) => {
format!(" Provider{} ", catalog_freshness_title_suffix())
}
(false, ProviderListView::Catalog) => {
format!(" Provider · all{} ", catalog_freshness_title_suffix())
}
(false, ProviderListView::Local) => " Provider · local only ".to_string(),
};
let outer = Block::default()
.title(Line::from(Span::styled(
title,
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let view_action = match self.view {
ProviderListView::Configured => self.tr(MessageId::PickerActionBrowseAll),
ProviderListView::Catalog => self.tr(MessageId::PickerActionConfigured),
ProviderListView::Local => self.tr(MessageId::PickerActionBrowseAll),
};
let search_active = !self.query.trim().is_empty();
let content = if search_active {
render_modal_footer(
inner,
buf,
&[
ActionHint::new(
"Esc",
format!(
"{} / {}",
self.tr(MessageId::PickerActionClear),
self.tr(MessageId::PickerActionCancel)
),
),
ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
ActionHint::new("Enter", enter_action),
ActionHint::new("A", view_action.clone()),
ActionHint::new("L", "local only"),
ActionHint::new("I", "LM Studio"),
ActionHint::new("C", self.tr(MessageId::PickerActionCustom)),
ActionHint::new("D", "DS4"),
ActionHint::new("S", "SenseNova"),
],
)
} else {
render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
ActionHint::new("a-z", self.tr(MessageId::PickerActionJump)),
ActionHint::new("Enter", enter_action),
ActionHint::new("A", view_action),
ActionHint::new("L", "local only"),
ActionHint::new("I", "LM Studio"),
ActionHint::new("C", self.tr(MessageId::PickerActionCustom)),
ActionHint::new("D", "DS4"),
ActionHint::new("S", "SenseNova"),
ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)),
ActionHint::new("X", self.tr(MessageId::ProviderExternalActionRevoke)),
ActionHint::new("M", self.tr(MessageId::PickerActionModels)),
ActionHint::new("Esc", self.tr(MessageId::PickerActionCancel)),
],
)
};
let filtered = self.filtered_rows();
if filtered.is_empty() {
if search_active {
EmptyState::new(
self.tr(MessageId::ProviderNoMatchesTitle),
self.tr(MessageId::ProviderNoMatchesHint),
)
.primary_action("Esc", self.tr(MessageId::PickerActionClearSearch))
.render(content, buf);
} else {
EmptyState::new(
self.tr(MessageId::ProviderNoConfiguredTitle),
self.tr(MessageId::ProviderNoConfiguredHint),
)
.primary_action("A", self.tr(MessageId::PickerActionBrowseAll))
.secondary_action("C", self.tr(MessageId::PickerActionCustom))
.render(content, buf);
}
return;
}
let layout = ListDetailLayout::split(content, 34);
let selected_pos = filtered
.iter()
.position(|(idx, _)| *idx == self.selected_idx)
.unwrap_or(0);
let visible_rows = usize::from(layout.list.height);
let visible_start = Self::visible_start(selected_pos, filtered.len(), visible_rows);
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
for (pos, (idx, row)) in filtered
.iter()
.enumerate()
.skip(visible_start)
.take(visible_rows)
{
let is_selected = *idx == self.selected_idx;
debug_assert_eq!(is_selected, pos == selected_pos);
let is_active = row.is_active;
let arrow = crate::tui::glyphs::selection_marker(is_selected);
let active_dot = if is_active { " *" } else { " " };
let spacer_style = if is_selected {
menu_style::selected_row_bg_style()
} else {
Style::default()
};
let label_style = if is_selected {
menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let has_usable_auth = matches!(
row.credential_state,
CredentialState::Saved
| CredentialState::ImportedToken
| CredentialState::NoAuth
| CredentialState::Local
| CredentialState::Legacy
);
let hint_style = if is_selected {
let hint_fg = if has_usable_auth {
palette::TEXT_MUTED
} else {
palette::STATUS_WARNING
};
menu_style::selected_row_style_with_fg(hint_fg)
} else if has_usable_auth {
Style::default().fg(palette::TEXT_MUTED)
} else {
Style::default().fg(palette::STATUS_WARNING)
};
let prefix = format!(" {arrow} {}{active_dot} ", row.display_name);
let hint = crate::tui::ui_text::semantic_truncate_between_affixes(
&prefix,
&row.list_row_hint(self.view),
"",
usize::from(layout.list.width),
);
let mut line = Line::from(vec![
Span::styled(" ", spacer_style),
Span::styled(arrow, label_style),
Span::styled(" ", spacer_style),
Span::styled(row.display_name.as_str(), label_style),
Span::styled(active_dot, label_style),
Span::styled(" ", spacer_style),
Span::styled(hint, hint_style),
]);
if is_selected {
line.style = menu_style::selected_row_bg_style();
let target_width = usize::from(layout.list.width);
let line_width = line.width();
if line_width < target_width {
line.spans.push(Span::styled(
" ".repeat(target_width - line_width),
menu_style::selected_row_bg_style(),
));
}
}
lines.push(line);
}
Paragraph::new(lines).render(layout.list, buf);
self.render_provider_detail(layout.detail, buf, &self.rows[self.selected_idx]);
}
fn render_provider_detail(&self, area: Rect, buf: &mut Buffer, row: &ProviderDashboardRow) {
if area.width == 0 || area.height == 0 {
return;
}
let block = Block::default()
.title(Line::from(Span::styled(
" Details ",
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default());
let inner = block.inner(area);
block.render(area, buf);
let route = if row.default_route.logical_model == row.default_route.wire_model {
row.default_route.logical_model.clone()
} else {
format!(
"{} -> {}",
row.default_route.logical_model, row.default_route.wire_model
)
};
let mut lines = vec![
Line::from(Span::styled(
row.display_name.clone(),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(
"{} | {} | {}",
row.readiness.label(),
row.auth_status.label(),
row.catalog_label()
),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
format!("Route: {route}"),
Style::default().fg(palette::TEXT_PRIMARY),
)),
Line::from(Span::styled(
format!("Endpoint: {}", row.base_url),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(
"Protocol: {} | Usage: {}",
row.supported_protocols.join("+"),
row.usage_meter
),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
format!("Capabilities: {}", row.capabilities.label()),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
format!("Reasoning: {}", row.reasoning.label()),
Style::default().fg(palette::TEXT_MUTED),
)),
];
if let Some(concurrency) = row.request_concurrency.label() {
lines.push(Line::from(Span::styled(
concurrency,
Style::default().fg(palette::TEXT_MUTED),
)));
}
for message in row.messages.iter().take(2) {
lines.push(Line::from(Span::styled(
format!("Note: {message}"),
Style::default().fg(palette::STATUS_WARNING),
)));
}
if let Some(status) = row.external_credential_status.as_ref() {
let state = if status.route_state == "active" {
self.tr(MessageId::CtxInspActive)
} else {
self.tr(MessageId::ProviderExternalDormant)
};
let scope = self
.tr(MessageId::ProviderExternalDetailScope)
.replace("{access}", status.access.as_str())
.replace("{provider}", &status.provider)
.replace("{source}", status.source.as_str())
.replace("{version}", &status.consent_version.to_string())
.replace("{state}", &state);
lines.push(Line::from(Span::styled(
scope,
Style::default().fg(palette::TEXT_MUTED),
)));
let owner_path = self
.tr(MessageId::ProviderExternalOwnerPath)
.replace("{owner}", status.owner)
.replace("{path}", &codewhale_config::quote_os_path(&status.path));
let mut owner_path_spans = vec![Span::styled(
owner_path,
Style::default().fg(palette::TEXT_MUTED),
)];
if status.ambient_path_changed {
let warning = self
.tr(MessageId::ProviderExternalPinnedPathWarning)
.replace("{owner}", status.owner)
.replace("{path}", &codewhale_config::quote_os_path(&status.path));
owner_path_spans.push(Span::styled(
" | ",
Style::default().fg(palette::TEXT_MUTED),
));
owner_path_spans.push(Span::styled(
warning,
Style::default().fg(palette::STATUS_WARNING),
));
}
lines.push(Line::from(owner_path_spans));
let semantics = match status.access {
codewhale_config::ExternalCredentialAccess::Disabled => {
self.tr(MessageId::ProviderExternalDisabledDetail)
}
codewhale_config::ExternalCredentialAccess::ReadOnly => {
self.tr(MessageId::ProviderExternalReadOnlySemantics)
}
codewhale_config::ExternalCredentialAccess::Managed => {
self.tr(MessageId::ProviderExternalManagedDetail)
}
};
lines.push(Line::from(Span::styled(
semantics,
Style::default().fg(palette::TEXT_MUTED),
)));
let revoke = self
.tr(MessageId::ProviderExternalRevoke)
.replace("{revoke}", &status.revoke_command);
lines.push(Line::from(Span::styled(
revoke,
Style::default().fg(palette::TEXT_MUTED),
)));
}
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.render(inner, buf);
}
fn render_xai_auth_choice(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.title(Line::from(Span::styled(
self.tr(MessageId::XaiAuthChoiceTitle),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓/1-2", self.tr(MessageId::ProviderExternalActionChoose)),
ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
ActionHint::new("E", self.tr(MessageId::ProviderExternalActionReuseGrok)),
ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
],
);
let marker = |choice| crate::tui::glyphs::selection_marker(self.xai_auth_choice == choice);
Paragraph::new(vec![
Line::from(self.tr(MessageId::XaiAuthChoiceIntro)),
Line::from(""),
Line::from(format!(
"{} 1. {}",
marker(XaiAuthChoice::ApiKey),
self.tr(MessageId::XaiAuthChoiceApiKeyOption),
)),
Line::from(format!(
"{} 2. {}",
marker(XaiAuthChoice::DeviceOAuth),
self.tr(MessageId::XaiAuthChoiceDeviceOAuthOption),
)),
])
.wrap(Wrap { trim: false })
.render(content, buf);
}
fn render_key_entry(&self, area: Rect, buf: &mut Buffer) {
let row = &self.rows[self.selected_idx];
let codex_oauth = row.provider == ApiProvider::OpenaiCodex;
let oauth_provider = codex_oauth;
let saved_credential = !oauth_provider && row.has_key;
let outer = Block::default()
.title(Line::from(Span::styled(
if oauth_provider {
format!(" OAuth login — {} ", row.display_name)
} else {
format!(" API key — {} ", row.display_name)
},
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = if codex_oauth {
render_modal_footer(
inner,
buf,
&[
ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionChoices)),
ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
],
)
} else if saved_credential && self.api_key_input.trim().is_empty() {
render_modal_footer(
inner,
buf,
&[
ActionHint::new("Type/paste", "replace saved key"),
ActionHint::new("Esc", "keep current key"),
],
)
} else {
render_modal_footer(
inner,
buf,
&[
ActionHint::new("Enter", "continue"),
ActionHint::new("Esc", "back"),
],
)
};
let masked = mask_key(&self.api_key_input);
let display = if codex_oauth {
"(run codex login; then explicitly grant read-only access)".to_string()
} else if masked.is_empty() && saved_credential {
"Saved credential configured".to_string()
} else if masked.is_empty() {
"(paste key here)".to_string()
} else {
masked
};
let key_lines = vec![Line::from(vec![
Span::styled(
if oauth_provider { "Auth: " } else { "Key: " },
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
display,
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
])];
let reopen_command = if self.setup_mode {
"/setup provider"
} else {
"/provider"
};
let mut hint_lines = if codex_oauth {
vec![
Line::from(Span::styled(
self.tr(MessageId::ProviderExternalHintCodexReview)
.replace("{login}", "codex login"),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
format!(
"Or set {} / CODEX_ACCESS_TOKEN and re-open {reopen_command}.",
self.env_var_for_selected_row(),
),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
"CLI: codewhale auth external-consent --provider openai-codex; no token is stored here.",
Style::default().fg(palette::TEXT_MUTED),
)),
]
} else if saved_credential && self.api_key_input.trim().is_empty() {
vec![Line::from(Span::styled(
"This terminal can use the stored credential. Type or paste only to replace it; Esc keeps it unchanged.",
Style::default().fg(palette::TEXT_MUTED),
))]
} else if saved_credential {
vec![Line::from(Span::styled(
"The replacement is validated before it replaces the stored credential.",
Style::default().fg(palette::TEXT_MUTED),
))]
} else {
vec![Line::from(Span::styled(
format!(
"Or set the {} environment variable and re-open {reopen_command}.",
self.env_var_for_selected_row(),
),
Style::default().fg(palette::TEXT_MUTED),
))]
};
if !oauth_provider {
if row.provider == ApiProvider::Moonshot
&& crate::config::moonshot_base_url_is_exact_kimi_code(&row.base_url)
{
hint_lines.extend([
Line::from(Span::styled(
self.tr(MessageId::KimiCodePlanApiKeyHint).replace(
"{console}",
crate::config::KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL,
),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
self.tr(MessageId::KimiCodePlanRouteHint)
.replace("{route}", crate::config::DEFAULT_KIMI_CODE_BASE_URL),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(Span::styled(
self.tr(MessageId::KimiCodePlanNoImportHint),
Style::default().fg(palette::TEXT_MUTED),
)),
]);
} else {
let help = row.provider.credential_help();
hint_lines.push(Line::from(Span::styled(
help.credential_url.map_or_else(
|| format!("Credentials: {}", help.guidance),
|url| format!("Credentials: {url}"),
),
Style::default().fg(palette::TEXT_MUTED),
)));
}
};
if let Some(ref error) = self.key_entry_error {
hint_lines.push(Line::from(Span::styled(
format!("Verification failed: {error}"),
Style::default().fg(palette::STATUS_ERROR),
)));
}
let hint = Paragraph::new(hint_lines).wrap(Wrap { trim: true });
let hint_height = u16::try_from(hint.line_count(content.width.max(1)))
.unwrap_or(u16::MAX)
.clamp(1, 6);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(hint_height),
Constraint::Min(1),
])
.split(content);
Paragraph::new(key_lines).render(layout[0], buf);
hint.render(layout[1], buf);
}
fn render_external_consent_choice(&self, area: Rect, buf: &mut Buffer) {
let provider_name = self.rows[self.selected_idx].display_name.clone();
let outer = Block::default()
.title(Line::from(Span::styled(
self.tr(MessageId::ProviderExternalChoiceTitle)
.replace("{provider}", &provider_name),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", self.tr(MessageId::ProviderExternalActionChoose)),
ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
],
);
let selected = self.external_consent_choice;
let row = |choice, label: Cow<'static, str>, detail: Cow<'static, str>| {
let marker = crate::tui::glyphs::selection_marker(selected == choice);
Line::from(vec![
Span::styled(
format!("{marker} {label}"),
Style::default().fg(if selected == choice {
palette::WHALE_INFO
} else {
palette::TEXT_PRIMARY
}),
),
Span::styled(
format!(" · {detail}"),
Style::default().fg(palette::TEXT_MUTED),
),
])
};
Paragraph::new(vec![
Line::from(self.tr(MessageId::ProviderExternalChoiceIntro)),
Line::from(""),
row(
ExternalConsentChoice::Disabled,
self.tr(MessageId::ProviderExternalDisabledLabel),
self.tr(MessageId::ProviderExternalDisabledDetail),
),
row(
ExternalConsentChoice::ReadOnly,
self.tr(MessageId::ProviderExternalReadOnlyLabel),
self.tr(MessageId::ProviderExternalReadOnlyDetail),
),
row(
ExternalConsentChoice::ManagedUnavailable,
self.tr(MessageId::ProviderExternalManagedLabel),
self.tr(MessageId::ProviderExternalManagedDetail),
),
])
.wrap(Wrap { trim: false })
.render(content, buf);
}
fn render_external_consent_confirm(&self, area: Rect, buf: &mut Buffer) {
let Some((provider, source, path)) = self.selected_external_consent_target() else {
return;
};
let outer = Block::default()
.title(Line::from(Span::styled(
self.tr(MessageId::ProviderExternalConfirmTitle),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionGrant)),
ActionHint::new("Esc", self.tr(MessageId::SetupActionCancel)),
],
);
let provider_label = self.tr(MessageId::RouteProviderLabel);
let owner_label = self.tr(MessageId::ProviderExternalOwnerLabel);
let exact_path_label = self.tr(MessageId::ProviderExternalExactPathLabel);
let semantics_label = self.tr(MessageId::ProviderExternalSemanticsLabel);
let revoke_label = self.tr(MessageId::ProviderExternalRevokeLabel);
Paragraph::new(vec![
Line::from(format!("{provider_label}: {}", provider.as_str())),
Line::from(format!(
"{owner_label}: {} ({})",
source.owner_label(),
source.as_str()
)),
Line::from(format!(
"{exact_path_label}: {}",
codewhale_config::quote_os_path(&path)
)),
Line::from(""),
Line::from(format!(
"{semantics_label}: {}.",
self.tr(MessageId::ProviderExternalReadOnlySemantics)
)),
Line::from(self.tr(MessageId::ProviderExternalRejectUnsafe)),
Line::from(format!(
"{revoke_label}: codewhale auth external-revoke --provider {}",
provider.as_str()
)),
])
.wrap(Wrap { trim: false })
.render(content, buf);
}
fn render_model_pick(&self, area: Rect, buf: &mut Buffer) {
let provider_name = self.rows[self.selected_idx].display_name.clone();
let outer = Block::default()
.title(Line::from(Span::styled(
format!(" Default model · {provider_name} "),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", "move"),
ActionHint::new("Enter", "continue"),
ActionHint::new("Esc", "back"),
],
);
let header = Paragraph::new(Line::from(Span::styled(
"Connection checked (/models returned 2xx). Pick a default model; model availability is not checked.",
Style::default().fg(palette::TEXT_MUTED),
)));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(content);
header.render(layout[0], buf);
let list_area = layout[1];
let visible_rows = usize::from(list_area.height);
let visible_start = Self::visible_start(
self.model_selected_idx,
self.model_options.len(),
visible_rows,
);
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
for (idx, model) in self
.model_options
.iter()
.enumerate()
.skip(visible_start)
.take(visible_rows)
{
let is_selected = idx == self.model_selected_idx;
let arrow = crate::tui::glyphs::selection_marker(is_selected);
let label_style = if is_selected {
menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let default_tag = if self.rows[self.selected_idx]
.default_route
.logical_model
.eq_ignore_ascii_case(model)
{
"default"
} else {
""
};
let mut line = Line::from(vec![
Span::styled(format!(" {arrow} {model}"), label_style),
if default_tag.is_empty() {
Span::raw("")
} else {
Span::styled(
format!(" ({default_tag})"),
if is_selected {
menu_style::selected_row_style_with_fg(palette::TEXT_MUTED)
} else {
Style::default().fg(palette::TEXT_MUTED)
},
)
},
]);
if is_selected {
line.style = menu_style::selected_row_bg_style();
}
lines.push(line);
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
self.tr(MessageId::ProviderNoCatalogModels),
Style::default().fg(palette::TEXT_MUTED),
)));
}
Paragraph::new(lines).render(list_area, buf);
}
fn render_plan_tier(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.title(Line::from(Span::styled(
" Kimi Code plan tier ",
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", "choose"),
ActionHint::new("Enter", "continue"),
ActionHint::new("Esc", "back"),
],
);
let selected = self.kimi_code_plan_tier;
let marker = |tier| crate::tui::glyphs::selection_marker(selected == tier);
Paragraph::new(vec![
Line::from("Kimi Code plan limits determine the context window used for k3."),
Line::from("Choose the tier you actually have; the safe floor is selected by default."),
Line::from(""),
Line::from(format!(
"{} 1. 262K context (safe default)",
marker(KimiCodePlanTier::Safe262k)
)),
Line::from(format!(
"{} 2. 1M context (only with an eligible plan)",
marker(KimiCodePlanTier::OneMillion)
)),
])
.wrap(Wrap { trim: false })
.render(content, buf);
}
fn render_stepfun_billing_route(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.title(Line::from(Span::styled(
format!(" {} ", self.tr(MessageId::StepfunBillingRouteTitle)),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", "choose"),
ActionHint::new("Enter", "continue"),
ActionHint::new("Esc", "back"),
],
);
let selected = self.stepfun_billing_route;
let marker = |route| crate::tui::glyphs::selection_marker(selected == route);
Paragraph::new(vec![
Line::from(self.tr(MessageId::StepfunBillingRouteIntro).to_string()),
Line::from(""),
Line::from(format!(
"{} 1. {} — {}",
marker(StepfunBillingRoute::PayAsYouGo),
self.tr(MessageId::StepfunBillingRoutePaygOption),
StepfunBillingRoute::PayAsYouGo.base_url(),
)),
Line::from(format!(
"{} 2. {} — {}",
marker(StepfunBillingRoute::StepPlan),
self.tr(MessageId::StepfunBillingRoutePlanOption),
StepfunBillingRoute::StepPlan.base_url(),
)),
])
.wrap(Wrap { trim: false })
.render(content, buf);
}
fn render_confirm(&self, area: Rect, buf: &mut Buffer) {
let row = &self.rows[self.selected_idx];
let outer = Block::default()
.title(Line::from(Span::styled(
" Confirm provider setup ",
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("Enter", "save & switch"),
ActionHint::new("Esc", "back"),
],
);
let masked = self
.pending_api_key
.as_deref()
.map(mask_key)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "(none)".to_string());
let model = self
.selected_model
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("(none)");
let lines = vec![
Line::from(Span::styled(
"Review before saving. Nothing is written until you confirm.",
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(vec![
Span::styled("Provider: ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(
row.display_name.clone(),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("API key: ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(masked, Style::default().fg(palette::TEXT_PRIMARY)),
]),
Line::from(vec![
Span::styled("Model: ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(
model.to_string(),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
]),
if let Some(context_window) = self.selected_context_window {
Line::from(format!("Context: {} tokens", context_window))
} else {
Line::from("")
},
];
Paragraph::new(lines).render(content, buf);
}
fn render_custom_form(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.title(Line::from(Span::styled(
" Custom provider ",
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG));
let inner = outer.inner(area);
outer.render(area, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("Tab/↑↓", "field"),
ActionHint::new("Enter", "next/save"),
ActionHint::new("Esc", "back"),
],
);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(content);
Paragraph::new(Line::from(Span::styled(
"OpenAI-compatible endpoint. Store an env var name, not a raw key. OpenCode Zen/Go are first-class rows. SenseNova: S. Agnes has no published URL.",
Style::default().fg(palette::TEXT_MUTED),
)))
.render(layout[0], buf);
self.render_custom_form_field(layout[1], buf, CustomProviderField::Name, "Name", "acme_ai");
self.render_custom_form_field(
layout[2],
buf,
CustomProviderField::BaseUrl,
"Base URL",
"https://api.example.com/v1",
);
self.render_custom_form_field(
layout[3],
buf,
CustomProviderField::Model,
"Default model",
"optional",
);
self.render_custom_form_field(
layout[4],
buf,
CustomProviderField::ApiKeyEnv,
"API key env",
"optional",
);
}
fn render_custom_form_field(
&self,
area: Rect,
buf: &mut Buffer,
field: CustomProviderField,
label: &str,
placeholder: &str,
) {
let selected = self.custom_provider_field == field;
let marker = crate::tui::glyphs::selection_marker(selected);
let value = self.custom_form_field_value(field);
let display = if value.is_empty() { placeholder } else { value };
let value_style = if selected {
menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
} else if value.is_empty() {
Style::default().fg(palette::TEXT_MUTED)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let label_style = if selected {
menu_style::selected_row_style_with_fg(palette::WHALE_INFO)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let mut line = Line::from(vec![
Span::styled(marker, label_style),
Span::styled(" ", label_style),
Span::styled(format!("{label}: "), label_style),
Span::styled(
crate::tui::ui_text::truncate_line_to_width(
display,
usize::from(area.width).saturating_sub(18),
),
value_style,
),
]);
if selected {
line.style = menu_style::selected_row_bg_style();
}
Paragraph::new(line).render(area, 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 {
match self.stage {
Stage::KeyEntry => {
if self.key_entry_is_oauth_locked() {
return true;
}
let sanitized: String = text.chars().filter(|c| !c.is_whitespace()).collect();
if !sanitized.is_empty() {
self.api_key_input.push_str(&sanitized);
self.key_entry_error = None;
}
true
}
Stage::CustomForm => {
let sanitized = text.replace(['\r', '\n', '\t'], " ");
self.custom_form_field_mut().push_str(sanitized.trim());
true
}
Stage::List
| Stage::XaiAuthChoice
| Stage::ExternalConsentChoice
| Stage::ExternalConsentConfirm
| Stage::ModelPick
| Stage::PlanTier
| Stage::StepfunBillingRoute
| Stage::Confirm => false,
}
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match self.stage {
Stage::List => match key.code {
KeyCode::Esc if !self.query.is_empty() => {
self.update_query(String::new());
ViewAction::None
}
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
catalog_view: self.view == ProviderListView::Catalog,
selected_provider_id: self
.rows
.get(self.selected_idx)
.map(|row| row.provider_id.clone()),
}),
KeyCode::Up => {
self.move_up();
ViewAction::None
}
KeyCode::Down => {
self.move_down();
ViewAction::None
}
KeyCode::Enter if self.row_visible(self.selected_idx) => {
let provider = self.selected_provider();
let provider_id = self.selected_provider_id();
if provider == ApiProvider::Custom
&& !self.rows[self.selected_idx].is_configured
{
self.enter_custom_form();
ViewAction::None
} else if !self.selected_route_is_valid() {
ViewAction::None
} else if self.selected_has_key() {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider,
provider_id,
})
} else if external_consent_target_is_grantable(provider) {
if let Some(event) = self.build_external_consent_event() {
ViewAction::EmitAndClose(event)
} else {
self.begin_setup();
ViewAction::None
}
} else {
self.begin_setup();
ViewAction::None
}
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'x')
&& self.row_visible(self.selected_idx)
&& self.rows[self.selected_idx].credential_state
== CredentialState::ExternalConsent =>
{
ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
provider: self.selected_provider(),
})
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& c.eq_ignore_ascii_case(&'e')
&& self.query.is_empty()
&& self.row_visible(self.selected_idx)
&& self.selected_external_consent_target().is_some() =>
{
self.enter_external_consent_choice();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& c.eq_ignore_ascii_case(&'r')
&& self.query.is_empty()
&& self.row_visible(self.selected_idx) =>
{
self.begin_setup();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'a') =>
{
self.toggle_view();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'l') =>
{
self.show_local_routes();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'i') =>
{
self.enter_lm_studio_form();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'c') =>
{
self.enter_custom_form();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'d') =>
{
self.enter_ds4_form();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'s') =>
{
self.enter_sensenova_form();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'m')
&& self.row_visible(self.selected_idx) =>
{
let provider = self.selected_provider();
let provider_id = self.selected_provider_id();
ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
provider,
provider_id,
})
}
KeyCode::Backspace if !self.query.is_empty() => {
let mut query = self.query.clone();
query.pop();
self.update_query(query);
ViewAction::None
}
KeyCode::Char(ch)
if key.modifiers.is_empty()
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
let mut query = self.query.clone();
query.push(ch);
self.update_query(query);
ViewAction::None
}
_ => ViewAction::None,
},
Stage::XaiAuthChoice => match key.code {
KeyCode::Esc => {
self.stage = Stage::List;
ViewAction::None
}
KeyCode::Up | KeyCode::Down => {
self.move_xai_auth_choice();
ViewAction::None
}
KeyCode::Char('1') => {
self.xai_auth_choice = XaiAuthChoice::ApiKey;
ViewAction::None
}
KeyCode::Char('2') => {
self.xai_auth_choice = XaiAuthChoice::DeviceOAuth;
ViewAction::None
}
KeyCode::Char(c) if key.modifiers.is_empty() && c.eq_ignore_ascii_case(&'e') => {
self.enter_external_consent_choice();
ViewAction::None
}
KeyCode::Enter => match self.xai_auth_choice {
XaiAuthChoice::ApiKey => {
self.enter_key_entry();
ViewAction::None
}
XaiAuthChoice::DeviceOAuth => {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
}
},
_ => ViewAction::None,
},
Stage::KeyEntry => match key.code {
KeyCode::Esc => {
self.stage = if self.selected_provider() == ApiProvider::Xai {
Stage::XaiAuthChoice
} else if self.pending_base_url.is_some() {
Stage::StepfunBillingRoute
} else {
Stage::List
};
self.api_key_input.clear();
self.key_entry_error = None;
self.pending_api_key = None;
self.model_options.clear();
self.model_selected_idx = 0;
self.selected_model = None;
ViewAction::None
}
KeyCode::Backspace => {
if !self.key_entry_is_oauth_locked() {
self.api_key_input.pop();
self.key_entry_error = None;
}
ViewAction::None
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if !self.key_entry_is_oauth_locked() {
self.api_key_input.pop();
self.key_entry_error = None;
}
ViewAction::None
}
KeyCode::Enter => {
if self.selected_provider() == ApiProvider::OpenaiCodex {
self.enter_external_consent_choice();
return ViewAction::None;
}
let key = self.api_key_input.trim().to_string();
if key.is_empty() {
ViewAction::None
} else {
let provider = self.selected_provider();
let provider_id = self.selected_provider_id();
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
provider,
provider_id,
api_key: key,
base_url: self.pending_base_url.clone(),
})
}
}
KeyCode::Char(c)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
if self.key_entry_is_oauth_locked() {
return ViewAction::None;
}
if !c.is_whitespace() {
self.api_key_input.push(c);
self.key_entry_error = None;
}
ViewAction::None
}
_ => ViewAction::None,
},
Stage::ExternalConsentChoice => match key.code {
KeyCode::Esc => {
self.stage = if self.selected_provider() == ApiProvider::Xai {
Stage::XaiAuthChoice
} else {
Stage::KeyEntry
};
ViewAction::None
}
KeyCode::Up => {
self.move_external_consent_choice(-1);
ViewAction::None
}
KeyCode::Down => {
self.move_external_consent_choice(1);
ViewAction::None
}
KeyCode::Char('1') => {
self.external_consent_choice = ExternalConsentChoice::Disabled;
ViewAction::None
}
KeyCode::Char('2') => {
self.external_consent_choice = ExternalConsentChoice::ReadOnly;
ViewAction::None
}
KeyCode::Char('3') => {
self.external_consent_choice = ExternalConsentChoice::ManagedUnavailable;
ViewAction::None
}
KeyCode::Enter => match self.external_consent_choice {
ExternalConsentChoice::Disabled => {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
provider: self.selected_provider(),
})
}
ExternalConsentChoice::ReadOnly => {
self.stage = Stage::ExternalConsentConfirm;
ViewAction::None
}
ExternalConsentChoice::ManagedUnavailable => ViewAction::None,
},
_ => ViewAction::None,
},
Stage::ExternalConsentConfirm => match key.code {
KeyCode::Esc => {
self.stage = Stage::ExternalConsentChoice;
ViewAction::None
}
KeyCode::Enter => self
.build_external_consent_event()
.map(ViewAction::EmitAndClose)
.unwrap_or(ViewAction::None),
_ => ViewAction::None,
},
Stage::ModelPick => match key.code {
KeyCode::Esc => {
self.stage = Stage::KeyEntry;
if let Some(pending) = self.pending_api_key.clone() {
self.api_key_input = pending;
}
self.key_entry_error = None;
ViewAction::None
}
KeyCode::Up => {
self.move_model_selection(-1);
ViewAction::None
}
KeyCode::Down => {
self.move_model_selection(1);
ViewAction::None
}
KeyCode::Enter => {
if self.model_options.is_empty() {
return ViewAction::None;
}
self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
if self.selected_kimi_code_k3() {
self.enter_plan_tier();
} else {
self.enter_confirm();
}
ViewAction::None
}
_ => ViewAction::None,
},
Stage::StepfunBillingRoute => match key.code {
KeyCode::Esc => {
self.stage = Stage::List;
self.pending_base_url = None;
ViewAction::None
}
KeyCode::Up | KeyCode::Down => {
self.stepfun_billing_route = match self.stepfun_billing_route {
StepfunBillingRoute::PayAsYouGo => StepfunBillingRoute::StepPlan,
StepfunBillingRoute::StepPlan => StepfunBillingRoute::PayAsYouGo,
};
ViewAction::None
}
KeyCode::Char('1') => {
self.stepfun_billing_route = StepfunBillingRoute::PayAsYouGo;
ViewAction::None
}
KeyCode::Char('2') => {
self.stepfun_billing_route = StepfunBillingRoute::StepPlan;
ViewAction::None
}
KeyCode::Enter => {
self.apply_stepfun_billing_route();
ViewAction::None
}
_ => ViewAction::None,
},
Stage::PlanTier => match key.code {
KeyCode::Esc => {
self.stage = Stage::ModelPick;
ViewAction::None
}
KeyCode::Up | KeyCode::Down => {
self.kimi_code_plan_tier = match self.kimi_code_plan_tier {
KimiCodePlanTier::Safe262k => KimiCodePlanTier::OneMillion,
KimiCodePlanTier::OneMillion => KimiCodePlanTier::Safe262k,
};
ViewAction::None
}
KeyCode::Char('1') => {
self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
ViewAction::None
}
KeyCode::Char('2') => {
self.kimi_code_plan_tier = KimiCodePlanTier::OneMillion;
ViewAction::None
}
KeyCode::Enter => {
self.apply_plan_tier();
ViewAction::None
}
_ => ViewAction::None,
},
Stage::Confirm => match key.code {
KeyCode::Esc => {
self.stage = if self.selected_kimi_code_k3() {
Stage::PlanTier
} else {
Stage::ModelPick
};
ViewAction::None
}
KeyCode::Enter => self
.build_setup_confirmed_event()
.map(ViewAction::EmitAndClose)
.unwrap_or(ViewAction::None),
_ => ViewAction::None,
},
Stage::CustomForm => match key.code {
KeyCode::Esc => {
self.stage = Stage::List;
ViewAction::None
}
KeyCode::Tab | KeyCode::Down => {
self.advance_custom_field();
ViewAction::None
}
KeyCode::BackTab | KeyCode::Up => {
self.retreat_custom_field();
ViewAction::None
}
KeyCode::Backspace => {
self.custom_form_field_mut().pop();
ViewAction::None
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.custom_form_field_mut().pop();
ViewAction::None
}
KeyCode::Enter if self.custom_provider_field != CustomProviderField::ApiKeyEnv => {
self.advance_custom_field();
ViewAction::None
}
KeyCode::Enter => self
.build_custom_provider_event()
.map(ViewAction::EmitAndClose)
.unwrap_or(ViewAction::None),
KeyCode::Char(c)
if !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
self.custom_form_field_mut().push(c);
ViewAction::None
}
_ => ViewAction::None,
},
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
match self.stage {
Stage::List => match mouse.kind {
MouseEventKind::ScrollUp => self.move_up(),
MouseEventKind::ScrollDown => self.move_down(),
_ => {}
},
Stage::ModelPick => match mouse.kind {
MouseEventKind::ScrollUp => self.move_model_selection(-1),
MouseEventKind::ScrollDown => self.move_model_selection(1),
_ => {}
},
Stage::PlanTier
| Stage::StepfunBillingRoute
| Stage::XaiAuthChoice
| Stage::KeyEntry
| Stage::ExternalConsentChoice
| Stage::ExternalConsentConfirm
| Stage::Confirm
| Stage::CustomForm => {}
}
ViewAction::None
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let preferred_height = match self.stage {
Stage::List => (self.rows.len() as u16).saturating_add(2),
Stage::XaiAuthChoice => 12,
Stage::KeyEntry => 14,
Stage::ExternalConsentChoice => 12,
Stage::ExternalConsentConfirm => 13,
Stage::ModelPick => 12,
Stage::PlanTier => 10,
Stage::StepfunBillingRoute => 11,
Stage::Confirm => 10,
Stage::CustomForm => 12,
};
let popup_area = centered_modal_area(area, 120, preferred_height, 64, 8);
render_modal_surface(area, popup_area, buf);
match self.stage {
Stage::List => self.render_list(popup_area, buf),
Stage::XaiAuthChoice => self.render_xai_auth_choice(popup_area, buf),
Stage::KeyEntry => self.render_key_entry(popup_area, buf),
Stage::ExternalConsentChoice => self.render_external_consent_choice(popup_area, buf),
Stage::ExternalConsentConfirm => self.render_external_consent_confirm(popup_area, buf),
Stage::ModelPick => self.render_model_pick(popup_area, buf),
Stage::PlanTier => self.render_plan_tier(popup_area, buf),
Stage::StepfunBillingRoute => self.render_stepfun_billing_route(popup_area, buf),
Stage::Confirm => self.render_confirm(popup_area, buf),
Stage::CustomForm => self.render_custom_form(popup_area, buf),
}
}
}
fn non_empty_string(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn custom_provider_dashboard_rows(
active: ApiProvider,
config: &Config,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Vec<ProviderDashboardRow> {
let Some(providers) = config.providers.as_ref() else {
return Vec::new();
};
let mut ids: Vec<_> = providers.custom.keys().cloned().collect();
ids.sort_by_key(|id| id.to_ascii_lowercase());
ids.into_iter()
.filter(|id| {
providers
.custom_provider_config(id)
.is_some_and(|entry| entry.is_openai_compatible_custom())
})
.map(|id| {
ProviderDashboardRow::from_custom_config_with_runtime_status(
&id,
active,
config,
runtime_status,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::EnvVarGuard;
use crossterm::event::{KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn move_to_provider(picker: &mut ProviderPickerView, provider: ApiProvider) {
if let Some(idx) = picker.rows.iter().position(|row| row.provider == provider)
&& !picker.row_visible(idx)
{
picker.toggle_view();
}
let max_steps = picker.rows.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 failed_live_catalog_refresh_names_the_working_fallback() {
assert_eq!(
catalog_freshness_title_suffix_for(ModelsDevFreshness::Failed),
" · refresh failed; catalog available"
);
}
#[test]
fn provider_picker_semantically_truncates_dense_rows_at_narrow_width() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.toggle_view();
let text = render_text(&picker, 64, 16);
assert!(text.contains('…'), "{text}");
for (idx, line) in text.lines().enumerate() {
assert!(
crate::tui::ui_text::text_display_width(line) <= 64,
"line {idx} overflows: {line:?}"
);
}
}
#[test]
fn type_ahead_jumps_to_provider_by_first_letter() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.toggle_view();
for c in "zai".chars() {
picker.handle_key(key(KeyCode::Char(c)));
}
assert_eq!(picker.query, "zai");
let filtered = picker.filtered_rows();
assert!(!filtered.is_empty(), "search for 'zai' must match Z.ai");
assert!(
filtered
.iter()
.any(|(_, row)| row.provider == ApiProvider::Zai),
"Z.ai must be in filtered results: {:?}",
filtered
.iter()
.map(|(_, r)| &r.display_name)
.collect::<Vec<_>>()
);
assert_eq!(picker.selected_provider(), ApiProvider::Zai);
}
#[test]
fn compact_base_url_strips_scheme_and_caps_length() {
assert_eq!(
compact_base_url("https://api.deepseek.com/"),
"api.deepseek.com"
);
assert_eq!(
compact_base_url("http://localhost:9000/v1"),
"localhost:9000/v1"
);
let long = compact_base_url("https://api-us-west-2.example-region.company.com/v1/openai");
assert!(long.ends_with("..."), "expected an ellipsis, got {long:?}");
assert!(
long.chars().count() <= 24,
"capped to 24 cols, got {long:?}"
);
}
#[test]
fn mouse_scroll_moves_selection_in_list_stage() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.toggle_view();
let before = picker.selected_idx;
picker.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert_ne!(
picker.selected_idx, before,
"scroll down should advance the selection"
);
}
#[test]
fn picker_lists_all_providers() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let names: Vec<_> = picker
.rows
.iter()
.map(|row| row.display_name.as_str())
.collect();
assert_eq!(names.len(), ApiProvider::catalog().len());
assert!(names.contains(&"DeepSeek"));
assert!(names.contains(&"Alibaba Cloud Model Studio"));
assert_eq!(
names
.iter()
.filter(|name| name.contains("Alibaba Cloud Model Studio"))
.count(),
1
);
assert_eq!(names.iter().filter(|name| **name == "MiniMax").count(), 1);
assert_eq!(names.iter().filter(|name| **name == "DeepSeek").count(), 1);
let mut expected = names.clone();
expected.sort_by_key(|name| name.to_ascii_lowercase());
assert_eq!(
names, expected,
"provider picker must list providers in case-insensitive alphabetical order"
);
assert_ne!(names.first(), Some(&"DeepSeek"));
}
#[test]
fn default_view_shows_only_configured_providers() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
assert_eq!(picker.view, ProviderListView::Configured);
let visible: Vec<ApiProvider> = picker
.filtered_rows()
.iter()
.map(|(_, row)| row.provider)
.collect();
assert!(visible.contains(&ApiProvider::Deepseek), "{visible:?}");
assert!(
!visible.contains(&ApiProvider::Custom),
"the unused custom-provider placeholder slot isn't \"configured\": {visible:?}"
);
for unconfigured in [
ApiProvider::Zai,
ApiProvider::Openrouter,
ApiProvider::Novita,
ApiProvider::Ollama,
] {
assert!(
!visible.contains(&unconfigured),
"{unconfigured:?} has no credentials and isn't active: {visible:?}"
);
}
assert!(
picker.rows.len() > visible.len(),
"underlying data keeps every provider"
);
}
#[test]
fn explicit_provider_config_marks_provider_configured_without_active_or_key() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
base_url: Some("https://custom.openrouter.example/v1".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider == ApiProvider::Openrouter)
.expect("openrouter row");
assert!(row.is_configured);
assert!(!row.has_key, "explicit config doesn't imply a working key");
}
#[test]
fn empty_provider_headers_do_not_mark_provider_configured() {
let _env = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config {
providers: Some(crate::config::ProvidersConfig {
anthropic: crate::config::ProviderConfig {
http_headers: Some(std::collections::HashMap::new()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let anthropic = picker
.rows
.iter()
.find(|row| row.provider == ApiProvider::Anthropic)
.expect("anthropic row");
assert!(
!anthropic.is_configured,
"an empty deserialized header table is default state, not setup"
);
}
#[test]
fn non_empty_provider_headers_mark_provider_configured() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
anthropic: crate::config::ProviderConfig {
http_headers: Some(std::collections::HashMap::from([(
"X-Route".to_string(),
"custom".to_string(),
)])),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let anthropic = picker
.rows
.iter()
.find(|row| row.provider == ApiProvider::Anthropic)
.expect("anthropic row");
assert!(
anthropic.is_configured,
"a user-authored header is meaningful explicit provider setup"
);
}
#[test]
fn blank_provider_header_entries_do_not_mark_provider_configured() {
let _env = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config {
providers: Some(crate::config::ProvidersConfig {
anthropic: crate::config::ProviderConfig {
http_headers: Some(std::collections::HashMap::from([
(" ".to_string(), "value".to_string()),
("X-Blank".to_string(), " ".to_string()),
])),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
assert!(!crate::config::provider_is_configured_for_active(
&config,
ApiProvider::Anthropic,
ApiProvider::Deepseek,
));
}
#[test]
fn self_hosted_provider_not_auto_configured_without_explicit_setup() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let ollama = picker
.rows
.iter()
.find(|row| row.provider == ApiProvider::Ollama)
.expect("ollama row");
assert!(
ollama.has_key,
"self-hosted providers report has_key unconditionally"
);
assert!(
!ollama.is_configured,
"but that alone must not mark them configured"
);
let active_picker = ProviderPickerView::new(ApiProvider::Ollama, &config);
let active_ollama = active_picker
.rows
.iter()
.find(|row| row.provider == ApiProvider::Ollama)
.expect("ollama row");
assert!(active_ollama.is_configured);
}
#[test]
fn toggle_view_reveals_full_catalog_and_back() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let configured_count = picker.filtered_rows().len();
assert_eq!(picker.view, ProviderListView::Configured);
let action = picker.handle_key(key(KeyCode::Char('a')));
assert!(matches!(action, ViewAction::None));
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.filtered_rows().len(), picker.rows.len());
assert!(picker.filtered_rows().len() > configured_count);
picker.handle_key(key(KeyCode::Char('A')));
assert_eq!(picker.view, ProviderListView::Configured);
assert_eq!(picker.filtered_rows().len(), configured_count);
}
#[test]
fn key_entry_hint_uses_metadata_env_vars() {
assert_eq!(
ProviderPickerView::env_var_for(ApiProvider::NvidiaNim),
"NVIDIA_API_KEY / NVIDIA_NIM_API_KEY / DEEPSEEK_API_KEY"
);
}
#[test]
fn key_entry_hint_includes_provider_credential_url() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::NvidiaNim);
picker.handle_key(key(KeyCode::Enter));
let rendered = render_text(&picker, 120, 20);
assert!(rendered.contains("NVIDIA_API_KEY / NVIDIA_NIM_API_KEY / DEEPSEEK_API_KEY"));
assert!(rendered.contains("https://build.nvidia.com/settings/api-keys"));
}
#[test]
fn zai_key_entry_wraps_long_environment_guidance_without_hiding_credentials_url() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Zai);
picker.handle_key(key(KeyCode::Enter));
let rendered = render_text(&picker, 100, 20);
for name in [
"ZAI_API_KEY",
"Z_AI_API_KEY",
"ZHIPU_API_KEY",
"GLM_API_KEY",
] {
assert!(rendered.contains(name), "missing {name}:\n{rendered}");
}
assert!(rendered.contains("re-open /provider."), "{rendered}");
assert!(
rendered.contains("Credentials: https://z.ai/model-api"),
"{rendered}"
);
}
#[test]
fn kimi_key_entry_uses_the_direct_api_key_console_without_oauth_copy() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Moonshot);
picker.handle_key(key(KeyCode::Enter));
let rendered = render_text(&picker, 120, 20);
assert!(rendered.contains("https://platform.kimi.ai/console/api-keys"));
assert!(rendered.contains("paste key here"));
assert!(!rendered.contains("OAuth"));
assert!(!rendered.contains("device login"));
}
#[test]
fn kimi_code_plan_key_entry_uses_membership_route_guidance() {
let config = Config {
provider: Some("moonshot".to_string()),
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let mut picker = ProviderPickerView::new(ApiProvider::Moonshot, &config);
assert_eq!(picker.selected_provider(), ApiProvider::Moonshot);
picker.handle_key(key(KeyCode::Enter));
let rendered = render_text(&picker, 120, 24);
assert!(rendered.contains("https://www.kimi.com/code/console"));
assert!(rendered.contains("api.kimi.com/coding/v1"));
assert!(rendered.contains("does not import Kimi CLI credentials"));
assert!(!rendered.contains("https://platform.kimi.ai/console/api-keys"));
assert!(!rendered.contains("OAuth"));
}
#[test]
fn recovery_picker_keeps_active_route_and_esc_makes_no_change() {
let config = Config {
provider: Some("moonshot".to_string()),
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let mut picker = ProviderPickerView::new(ApiProvider::Moonshot, &config);
assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.selected_provider(), ApiProvider::Moonshot);
assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
));
assert_eq!(config.provider.as_deref(), Some("moonshot"));
assert_eq!(
config
.provider_config_for(ApiProvider::Moonshot)
.and_then(|entry| entry.base_url.as_deref()),
Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
);
}
#[test]
fn recovery_model_pick_restores_exact_kimi_code_k3_without_catalog_leakage() {
let mut config = Config {
provider: Some("moonshot".to_string()),
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Moonshot,
ApiProvider::Moonshot,
&config,
None,
"validated-key".to_string(),
None,
)
.expect("Kimi route row");
assert_eq!(picker.selected_model.as_deref(), Some("k3"));
assert_eq!(
picker
.model_options
.iter()
.filter(|model| model.eq_ignore_ascii_case("k3"))
.count(),
1,
"the current wire model must be appended once, case-insensitively"
);
config
.providers
.as_mut()
.expect("providers")
.moonshot
.base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
let generic = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Moonshot,
ApiProvider::Moonshot,
&config,
None,
"validated-key".to_string(),
None,
)
.expect("generic Moonshot row");
assert!(
!generic
.model_options
.iter()
.any(|model| model.eq_ignore_ascii_case("k3")),
"bare K3 stays route-local and must not be added to generic Moonshot"
);
}
#[test]
fn setup_provider_key_entry_matrix_keeps_hosted_codex_and_local_hints_distinct() {
let _guard = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let codewhale_home = tmp.path().join(".codewhale");
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let config = Config::default();
let hosted = ProviderPickerView::new_for_setup(
ApiProvider::Openai,
Some(ApiProvider::Deepseek),
&config,
None,
);
assert_eq!(hosted.stage, Stage::KeyEntry);
assert_eq!(hosted.selected_provider(), ApiProvider::Deepseek);
let hosted_text = render_text(&hosted, 120, 20);
assert!(hosted_text.contains("DEEPSEEK_API_KEY"), "{hosted_text}");
assert!(
hosted_text.contains("Credentials: https://platform.deepseek.com/api_keys"),
"{hosted_text}"
);
assert!(!hosted_text.contains("OAuth login"), "{hosted_text}");
let codex = ProviderPickerView::new_for_setup(
ApiProvider::Deepseek,
Some(ApiProvider::OpenaiCodex),
&config,
None,
);
assert_eq!(codex.stage, Stage::KeyEntry);
assert_eq!(codex.selected_provider(), ApiProvider::OpenaiCodex);
let codex_text = render_text(&codex, 120, 20);
assert!(codex_text.contains("OAuth login"), "{codex_text}");
assert!(
codex_text.contains("OPENAI_CODEX_ACCESS_TOKEN"),
"{codex_text}"
);
assert!(codex_text.contains("external-consent"), "{codex_text}");
assert!(!codex_text.contains("Credentials:"), "{codex_text}");
assert!(!codex_text.contains("(paste key here)"), "{codex_text}");
let local = ProviderPickerView::new_for_setup(
ApiProvider::Deepseek,
Some(ApiProvider::Ollama),
&config,
None,
);
assert_eq!(local.stage, Stage::List);
assert_eq!(local.selected_provider(), ApiProvider::Ollama);
let local_text = render_text(&local, 120, 20);
assert!(!local_text.contains("Credentials:"), "{local_text}");
let mut custom = std::collections::HashMap::new();
custom.insert(
"my_thing".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://api.example.com/v1".to_string()),
model: Some("vendor/custom-model-v1".to_string()),
api_key_env: Some("EXAMPLE_API_KEY".to_string()),
..Default::default()
},
);
let _custom_key = crate::test_support::EnvVarGuard::remove("EXAMPLE_API_KEY");
let custom_config = Config {
provider: Some("my_thing".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let custom_picker =
ProviderPickerView::new_for_setup(ApiProvider::Custom, None, &custom_config, None);
let custom_row = &custom_picker.rows[custom_picker.selected_idx];
assert_eq!(custom_row.provider, ApiProvider::Custom);
assert_eq!(custom_row.provider_id, "my_thing");
assert!(
custom_row
.messages
.iter()
.any(|message| message.contains("EXAMPLE_API_KEY")),
"custom setup row should name its configured auth env var: {:?}",
custom_row.messages
);
let custom_text = render_text(&custom_picker, 120, 20);
assert!(custom_text.contains("my_thing"), "{custom_text}");
assert!(custom_text.contains("EXAMPLE_API_KEY"), "{custom_text}");
assert!(!custom_text.contains("Credentials:"), "{custom_text}");
}
#[test]
fn provider_dashboard_row_models_local_readiness_without_rendering() {
let config = Config::default();
let row =
ProviderDashboardRow::from_config(ApiProvider::Ollama, ApiProvider::Ollama, &config);
assert_eq!(row.provider_id, "ollama");
assert_eq!(row.auth_status, ProviderAuthStatus::Local);
assert_eq!(row.readiness, ResolvedProviderReadiness::LocalUnchecked);
assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
assert_eq!(row.usage_meter, "cost: local");
assert!(row.base_url.contains("localhost:11434"));
assert!(row.is_active);
}
#[test]
fn ollama_cloud_row_requires_credentials_and_is_not_labeled_local() {
let _env_lock = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("isolated credential home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _ollama_cloud_key = EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
let _ollama_key = EnvVarGuard::remove("OLLAMA_API_KEY");
let _cli_source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let mut config = Config {
provider: Some("ollama".to_string()),
providers: Some(crate::config::ProvidersConfig {
ollama: crate::config::ProviderConfig {
base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
assert_eq!(config.api_provider(), ApiProvider::OllamaCloud);
let missing = ProviderDashboardRow::from_config(
ApiProvider::OllamaCloud,
ApiProvider::OllamaCloud,
&config,
);
assert_eq!(missing.auth_status, ProviderAuthStatus::Missing);
assert_eq!(missing.readiness, ResolvedProviderReadiness::MissingKey);
assert_eq!(missing.usage_meter, "cost: unknown");
assert!(!missing.compact_hint().contains("(self-hosted)"));
assert!(
missing
.messages
.iter()
.any(|message| message.contains("OLLAMA_API_KEY")),
"missing Cloud key guidance: {:?}",
missing.messages
);
config.providers.as_mut().expect("providers").ollama.api_key =
Some("ollama-cloud-key".to_string());
let configured = ProviderDashboardRow::from_config(
ApiProvider::OllamaCloud,
ApiProvider::OllamaCloud,
&config,
);
assert_eq!(configured.auth_status, ProviderAuthStatus::Configured);
assert_eq!(
configured.readiness,
ResolvedProviderReadiness::SavedUnchecked
);
assert!(!configured.compact_hint().contains("(self-hosted)"));
}
#[test]
fn deepseek_cn_row_uses_shared_readiness_and_strict_model_validation() {
let _lock = crate::test_support::lock_test_env();
let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
let missing = Config {
provider: Some("deepseek-cn".to_string()),
..Default::default()
};
let missing_row = ProviderDashboardRow::from_config(
ApiProvider::DeepseekCN,
ApiProvider::DeepseekCN,
&missing,
);
assert_eq!(missing_row.readiness, ResolvedProviderReadiness::MissingKey);
assert_ne!(missing_row.auth_status, ProviderAuthStatus::Legacy);
let configured = Config {
provider: Some("deepseek-cn".to_string()),
providers: Some(crate::config::ProvidersConfig {
deepseek_cn: crate::config::ProviderConfig {
api_key: Some("deepseek-cn-test-key".to_string()),
model: Some("deepseek-v4-pro".to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let configured_row = ProviderDashboardRow::from_config(
ApiProvider::DeepseekCN,
ApiProvider::DeepseekCN,
&configured,
);
assert_eq!(
configured_row.readiness,
ResolvedProviderReadiness::SavedUnchecked
);
let mut invalid = configured;
invalid
.providers
.as_mut()
.expect("providers")
.deepseek_cn
.model = Some("anthropic/claude-foreign".to_string());
let invalid_row = ProviderDashboardRow::from_config(
ApiProvider::DeepseekCN,
ApiProvider::DeepseekCN,
&invalid,
);
assert_eq!(
invalid_row.readiness,
ResolvedProviderReadiness::InvalidRoute
);
}
#[test]
fn provider_health_requires_observed_success_and_keeps_failure_reason() {
let config = Config {
api_key: Some("saved-key".to_string()),
..Config::default()
};
let unchecked = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let row = unchecked
.rows
.iter()
.find(|row| row.provider == ApiProvider::Deepseek)
.expect("DeepSeek row");
assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
let mut health = ProviderReadinessSnapshot::default();
health.record_success(&config, ApiProvider::Deepseek, "deepseek-v4-pro");
let ready =
ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health);
assert_eq!(
ready
.rows
.iter()
.find(|row| row.provider == ApiProvider::Deepseek)
.unwrap()
.readiness,
ResolvedProviderReadiness::Ready
);
health.record_failure_message(
&config,
ApiProvider::Deepseek,
"deepseek-v4-pro",
crate::error_taxonomy::ErrorCategory::Authentication,
"credential rejected",
);
let failed =
ProviderPickerView::new(ApiProvider::Deepseek, &config).with_provider_health(&health);
let row = failed
.rows
.iter()
.find(|row| row.provider == ApiProvider::Deepseek)
.unwrap();
assert!(row.readiness.label().contains("last check failed"));
assert!(
row.messages
.iter()
.any(|message| message == "credential rejected")
);
}
#[test]
fn openai_codex_row_is_experimental_and_tagged_in_hint() {
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::OpenaiCodex,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.maturity, ProviderMaturity::Experimental);
assert!(
row.compact_hint().contains("experimental"),
"experimental maturity must surface in the hint, got {:?}",
row.compact_hint()
);
}
#[test]
fn mainstream_provider_is_supported_without_experimental_tag() {
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::Deepseek,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.maturity, ProviderMaturity::Supported);
assert!(
!row.compact_hint().contains("experimental"),
"supported providers must omit the experimental tag, got {:?}",
row.compact_hint()
);
}
#[test]
fn provider_dashboard_row_surfaces_glm_reasoning_controls() {
let config = Config {
reasoning_effort: Some("max".to_string()),
providers: Some(crate::config::ProvidersConfig {
zai: crate::config::ProviderConfig {
api_key: Some("zai-key".to_string()),
model: Some("GLM-5.2".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Zai, &config);
assert_eq!(row.default_route.wire_model, "GLM-5.2");
assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
assert_eq!(
row.reasoning.controls,
vec!["high".to_string(), "max".to_string()]
);
assert_eq!(
row.reasoning.stream_visibility,
ProviderReasoningStreamVisibility::StructuredThinking
);
assert_eq!(row.reasoning.selected_control.as_deref(), Some("max"));
assert!(row.compact_hint().contains("reasoning:high/max"));
assert!(row.compact_hint().contains("stream:structured"));
}
#[test]
fn provider_dashboard_row_surfaces_modelstudio_structured_thinking() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
modelstudio_token_plan: crate::config::ProviderConfig {
api_key: Some("modelstudio-key".to_string()),
model: Some("qwen3.8-max".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::ModelstudioTokenPlan,
ApiProvider::ModelstudioTokenPlan,
&config,
);
assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
assert_eq!(
row.reasoning.stream_visibility,
ProviderReasoningStreamVisibility::StructuredThinking
);
assert!(row.compact_hint().contains("stream:structured"));
}
#[test]
fn provider_dashboard_row_surfaces_kimi_code_k3_reasoning_only_on_exact_route() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
api_key: Some("kimi-code-key".to_string()),
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::Moonshot,
ApiProvider::Moonshot,
&config,
);
assert_eq!(
row.default_route.wire_model,
crate::config::KIMI_CODE_K3_MODEL
);
assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
assert_eq!(
row.reasoning.stream_visibility,
ProviderReasoningStreamVisibility::StructuredThinking
);
assert_eq!(
row.reasoning.controls,
vec!["low".to_string(), "high".to_string(), "max".to_string()]
);
assert_eq!(
row.capabilities.context_window,
Some(262_144),
"the picker must show the route-effective K3 baseline, not the generic fallback"
);
assert_eq!(
row.capabilities.context_window_source.as_deref(),
Some("static Kimi Code safe floor"),
"the picker must name the provenance instead of presenting a bare limit as provider fact"
);
assert!(
row.compact_hint()
.contains("ctx:262K(static Kimi Code safe floor)"),
"the compact picker receipt must retain context provenance"
);
let mut direct = config.clone();
direct
.providers
.as_mut()
.expect("providers")
.moonshot
.base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
let direct_row = ProviderDashboardRow::from_config(
ApiProvider::Moonshot,
ApiProvider::Moonshot,
&direct,
);
assert_ne!(
direct_row.reasoning.support,
ProviderReasoningSupport::Supported,
"generic Moonshot k3 must not inherit Kimi Code's route-owned capability"
);
assert_ne!(
direct_row.capabilities.context_window_source.as_deref(),
Some("static Kimi Code safe floor")
);
}
#[test]
fn provider_row_query_matches_default_route_model_and_wire_id() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
zai: crate::config::ProviderConfig {
api_key: Some("zai-key".to_string()),
model: Some("GLM-5.2".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Zai, &config);
assert_eq!(row.default_route.wire_model, "GLM-5.2");
assert!(row.matches_query("glm-5.2"));
assert!(row.matches_query("GLM"));
assert!(row.matches_query("zhipu"));
assert!(!row.matches_query("anthropic"));
}
#[test]
fn provider_dashboard_row_surfaces_zai_concurrency_cap() {
let config = Config::default();
let row =
ProviderDashboardRow::from_config(ApiProvider::Zai, ApiProvider::Deepseek, &config);
assert_eq!(
row.request_concurrency.limit,
Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
);
assert_eq!(row.request_concurrency.active, None);
assert!(
row.compact_hint().contains("req:cap 3"),
"Z.ai's effective default cap must surface in /provider, got {:?}",
row.compact_hint()
);
}
#[test]
fn provider_dashboard_row_surfaces_active_provider_requests() {
let config = Config::default();
let runtime_status = ProviderRuntimeStatus {
provider: ApiProvider::Zai,
request_concurrency_limit: Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY),
active_provider_requests: 2,
};
let mut picker = ProviderPickerView::new_with_runtime_status(
ApiProvider::Zai,
&config,
Some(runtime_status),
);
move_to_provider(&mut picker, ApiProvider::Zai);
let row = &picker.rows[picker.selected_idx];
assert_eq!(
row.request_concurrency.limit,
Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
);
assert_eq!(row.request_concurrency.active, Some(2));
assert!(
row.compact_hint().contains("req:2/3"),
"active runtime concurrency must surface in /provider, got {:?}",
row.compact_hint()
);
}
#[test]
fn provider_dashboard_row_surfaces_codex_reasoning_scale() {
let config = Config {
reasoning_effort: Some("max".to_string()),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::OpenaiCodex,
ApiProvider::OpenaiCodex,
&config,
);
assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
assert_eq!(
row.reasoning.controls,
vec![
"low".to_string(),
"medium".to_string(),
"high".to_string(),
"xhigh".to_string(),
]
);
assert_eq!(
row.reasoning.stream_visibility,
ProviderReasoningStreamVisibility::StructuredThinking
);
assert_eq!(row.reasoning.selected_control.as_deref(), Some("xhigh"));
assert!(
row.compact_hint()
.contains("reasoning:low/medium/high/xhigh")
);
}
#[test]
fn provider_dashboard_row_surfaces_capability_and_metadata_badges() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
api_key: Some("deepseek-key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::Deepseek,
ApiProvider::Deepseek,
&config,
);
assert!(row.capabilities.context_window.is_some());
assert!(row.capabilities.max_output.is_some());
let hint = row.compact_hint();
assert!(hint.contains("ctx:"), "metadata badge missing: {hint}");
assert!(hint.contains("out:"), "metadata badge missing: {hint}");
for badge in ["tools:", "json:", "stream:", "cache:"] {
assert!(
hint.contains(badge),
"capability badge {badge} missing: {hint}"
);
}
}
#[test]
fn provider_dashboard_row_classifies_model_origin() {
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::Deepseek,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.model_origin, ProviderModelOrigin::Default);
assert!(row.compact_hint().contains("origin:default"));
let config = Config {
providers: Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
api_key: Some("k".to_string()),
model: Some("deepseek-v4-flash".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::Deepseek,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
assert!(row.compact_hint().contains("origin:saved"));
}
#[test]
fn model_origin_classifier_covers_default_saved_custom() {
assert_eq!(
ProviderModelOrigin::for_provider(ApiProvider::Deepseek, false),
ProviderModelOrigin::Default
);
assert_eq!(
ProviderModelOrigin::for_provider(ApiProvider::Deepseek, true),
ProviderModelOrigin::Saved
);
assert_eq!(
ProviderModelOrigin::for_provider(ApiProvider::Custom, false),
ProviderModelOrigin::Custom
);
assert_eq!(
ProviderModelOrigin::for_provider(ApiProvider::Custom, true),
ProviderModelOrigin::Saved
);
}
#[test]
fn self_hosted_provider_row_marks_self_hosted_in_hint() {
let _env_lock = crate::test_support::lock_test_env();
let _sglang_key = crate::test_support::EnvVarGuard::remove("SGLANG_API_KEY");
let _sglang_base_url = crate::test_support::EnvVarGuard::remove("SGLANG_BASE_URL");
let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
let _ollama_base_url = crate::test_support::EnvVarGuard::remove("OLLAMA_BASE_URL");
let config = Config::default();
let row =
ProviderDashboardRow::from_config(ApiProvider::Ollama, ApiProvider::Ollama, &config);
assert_eq!(row.auth_status, ProviderAuthStatus::Local);
assert!(
row.compact_hint().contains("(self-hosted)"),
"self-hosted hint missing: {}",
row.compact_hint()
);
let sglang =
ProviderDashboardRow::from_config(ApiProvider::Sglang, ApiProvider::Sglang, &config);
assert_eq!(sglang.auth_status, ProviderAuthStatus::Optional);
assert!(
sglang.compact_hint().contains("(self-hosted)"),
"self-hosted hint missing for SGLang: {}",
sglang.compact_hint()
);
}
#[test]
fn protected_self_hosted_row_requires_its_configured_auth_mode() {
let _env_lock = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("isolated credential home");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
let config = Config {
provider: Some("vllm".to_string()),
providers: Some(crate::config::ProvidersConfig {
vllm: crate::config::ProviderConfig {
auth_mode: Some("api_key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(ApiProvider::Vllm, ApiProvider::Vllm, &config);
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.credential_state, CredentialState::MissingKey);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert!(row.compact_hint().contains("(self-hosted)"));
}
#[test]
fn self_hosted_reasoning_visibility_covers_vllm() {
assert_eq!(
default_reasoning_stream_visibility(ApiProvider::Sglang),
ProviderReasoningStreamVisibility::StructuredThinking
);
assert_eq!(
default_reasoning_stream_visibility(ApiProvider::Vllm),
ProviderReasoningStreamVisibility::StructuredThinking
);
}
#[test]
fn humanize_token_count_is_compact_and_marks_unknown() {
assert_eq!(humanize_token_count(None), "?");
assert_eq!(humanize_token_count(Some(1_000_000)), "1M");
assert_eq!(humanize_token_count(Some(1_500_000)), "1.5M");
assert_eq!(humanize_token_count(Some(131_072)), "131K");
assert_eq!(humanize_token_count(Some(512)), "512");
}
#[test]
fn provider_dashboard_row_uses_route_resolver_for_custom_openai_endpoint() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
api_key: Some("openai-key".to_string()),
base_url: Some("http://localhost:9000/v1".to_string()),
model: Some("custom-model".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row =
ProviderDashboardRow::from_config(ApiProvider::Openai, ApiProvider::Openai, &config);
assert_eq!(row.provider_id, "openai");
assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
assert_eq!(row.base_url, "http://localhost:9000/v1");
assert_eq!(row.default_route.logical_model, "custom-model");
assert_eq!(row.default_route.wire_model, "custom-model");
assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
}
#[test]
fn custom_endpoint_cannot_claim_official_xai_oauth_readiness() {
let _lock = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("isolated oauth home");
let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
let missing_grok_auth = temp.path().join("missing.json");
let _grok_auth = EnvVarGuard::set(
"GROK_AUTH_PATH",
missing_grok_auth.to_str().expect("utf8 test path"),
);
let config = Config {
provider: Some("xai".to_string()),
providers: Some(crate::config::ProvidersConfig {
xai: crate::config::ProviderConfig {
base_url: Some("https://gateway.example.test/v1".to_string()),
model: Some("private-grok".to_string()),
auth_mode: Some("oauth".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(ApiProvider::Xai, ApiProvider::Xai, &config);
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.credential_state, CredentialState::MissingKey);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert!(!row.compact_hint().contains("oauth"));
}
#[test]
fn explicit_no_auth_custom_row_is_distinct_and_usable() {
let custom = std::collections::HashMap::from([(
"no-auth-gateway".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://gateway.example.test/v1".to_string()),
model: Some("private-model".to_string()),
auth_mode: Some("no-auth".to_string()),
..Default::default()
},
)]);
let config = Config {
provider: Some("no-auth-gateway".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider_id == "no-auth-gateway")
.expect("configured no-auth row");
assert_eq!(row.auth_status, ProviderAuthStatus::NoAuth);
assert_eq!(row.credential_state, CredentialState::NoAuth);
assert_eq!(row.readiness, ResolvedProviderReadiness::NoAuthUnchecked);
assert!(picker.selected_has_key());
assert!(row.compact_hint().contains("auth:none"));
}
#[test]
fn unresolved_custom_auth_metadata_does_not_mark_picker_row_configured() {
let custom = std::collections::HashMap::from([(
"metadata-only".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://gateway.example.test/v1".to_string()),
model: Some("private-model".to_string()),
auth: Some(codewhale_config::ProviderAuthSourceToml {
source: codewhale_config::AuthSourceKind::Command,
command: vec!["secret-tool".to_string(), "lookup".to_string()],
timeout_ms: Some(2_000),
secret_id: None,
}),
..Default::default()
},
)]);
let config = Config {
provider: Some("metadata-only".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider_id == "metadata-only")
.expect("metadata-only row remains visible for repair");
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.credential_state, CredentialState::MissingKey);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
}
#[test]
fn provider_picker_lists_configured_custom_provider_readiness() {
let _lock = crate::test_support::lock_test_env();
let _example_key = EnvVarGuard::remove("EXAMPLE_API_KEY");
let mut custom = std::collections::HashMap::new();
custom.insert(
"my_thing".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://api.example.com/v1".to_string()),
model: Some("vendor/custom-model-v1".to_string()),
api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
api_key_env: Some("EXAMPLE_API_KEY".to_string()),
..Default::default()
},
);
let config = Config {
provider: Some("my_thing".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider_id == "my_thing")
.expect("configured custom provider row");
assert_eq!(row.provider, ApiProvider::Custom);
assert_eq!(row.display_name, "my_thing (custom)");
assert_eq!(row.kind, "openai-compatible");
assert!(row.is_active);
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert_eq!(row.base_url, "https://api.example.com/v1");
assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
assert_eq!(row.default_route.logical_model, "vendor/custom-model-v1");
assert_eq!(row.default_route.wire_model, "vendor/custom-model-v1");
assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
assert!(
row.messages
.iter()
.any(|message| message.contains("EXAMPLE_API_KEY")),
"custom row should name the configured auth env var: {:?}",
row.messages
);
assert_eq!(picker.rows[picker.selected_idx].provider_id, "my_thing");
}
#[test]
fn provider_picker_marks_only_exact_active_custom_row() {
let custom = std::collections::HashMap::from([
(
"custom-a".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("model-a".to_string()),
api_key: Some("test-key-a".to_string()),
..Default::default()
},
),
(
"custom-b".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18182/v1".to_string()),
model: Some("model-b".to_string()),
api_key: Some("test-key-b".to_string()),
..Default::default()
},
),
]);
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let rows = custom_provider_dashboard_rows(ApiProvider::Custom, &config, None);
let active_ids: Vec<_> = rows
.iter()
.filter(|row| row.is_active)
.map(|row| row.provider_id.as_str())
.collect();
assert_eq!(active_ids, vec!["custom-a"]);
}
#[test]
fn provider_picker_marks_custom_provider_ready_when_env_auth_is_set() {
let _lock = crate::test_support::lock_test_env();
let _example_key = EnvVarGuard::set("EXAMPLE_API_KEY", "sk-test");
let mut custom = std::collections::HashMap::new();
custom.insert(
"my_thing".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://api.example.com/v1".to_string()),
model: Some("custom-model-v1".to_string()),
api_key_env: Some("EXAMPLE_API_KEY".to_string()),
..Default::default()
},
);
let config = Config {
provider: Some("my_thing".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let row = picker
.rows
.iter()
.find(|row| row.provider_id == "my_thing")
.expect("configured custom provider row");
assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
assert!(row.has_key);
assert!(
!row.messages
.iter()
.any(|message| message.contains("EXAMPLE_API_KEY")),
"configured custom auth should not report missing env var: {:?}",
row.messages
);
}
#[test]
fn custom_provider_form_emits_named_provider_without_secret_value() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
assert!(matches!(
picker.handle_key(key(KeyCode::Char('c'))),
ViewAction::None
));
assert_eq!(picker.stage, Stage::CustomForm);
for ch in "acme_ai".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
picker.handle_key(key(KeyCode::Enter));
for ch in "https://api.acme.example/v1".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
picker.handle_key(key(KeyCode::Enter));
for ch in "acme/code-1".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
picker.handle_key(key(KeyCode::Enter));
for ch in "ACME_API_KEY".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
let action = picker.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
provider_id,
base_url,
model,
api_key_env,
}) => {
assert_eq!(provider_id, "acme_ai");
assert_eq!(base_url, "https://api.acme.example/v1");
assert_eq!(model.as_deref(), Some("acme/code-1"));
assert_eq!(api_key_env.as_deref(), Some("ACME_API_KEY"));
}
other => panic!("expected custom provider submit event, got {other:?}"),
}
}
#[test]
fn sensenova_preset_fills_published_openai_host() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
assert!(matches!(
picker.handle_key(key(KeyCode::Char('s'))),
ViewAction::None
));
assert_eq!(picker.stage, Stage::CustomForm);
assert_eq!(picker.custom_provider_id, "sensenova");
assert_eq!(
picker.custom_provider_base_url,
"https://token.sensenova.cn/v1"
);
assert_eq!(picker.custom_provider_model, "deepseek-v4-flash");
assert_eq!(picker.custom_provider_api_key_env, "SENSENOVA_API_KEY");
}
#[test]
fn lm_studio_preset_is_loopback_keyless_and_requests_the_loaded_model() {
let config = Config::default();
let mut picker =
ProviderPickerView::new_for_onboarding(ApiProvider::Deepseek, None, &config, None);
assert_eq!(picker.view, ProviderListView::Local);
assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
let rendered = render_text(&picker, 100, 28);
assert!(rendered.contains("I LM Studio"), "{rendered}");
assert!(matches!(
picker.handle_key(key(KeyCode::Char('i'))),
ViewAction::None
));
assert_eq!(picker.stage, Stage::CustomForm);
assert_eq!(picker.custom_provider_field, CustomProviderField::Model);
assert_eq!(picker.custom_provider_id, "lm_studio");
assert_eq!(picker.custom_provider_base_url, "http://127.0.0.1:1234/v1");
assert!(picker.custom_provider_model.is_empty());
assert!(picker.custom_provider_api_key_env.is_empty());
for ch in "local-code-model".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
picker.handle_key(key(KeyCode::Enter));
let action = picker.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
provider_id,
base_url,
model,
api_key_env,
}) => {
assert_eq!(provider_id, "lm_studio");
assert_eq!(base_url, "http://127.0.0.1:1234/v1");
assert_eq!(model.as_deref(), Some("local-code-model"));
assert_eq!(api_key_env, None);
}
other => panic!("expected LM Studio custom-provider submit event, got {other:?}"),
}
}
#[test]
fn ds4_preset_is_keyless_and_ready_to_save() {
let mut picker =
ProviderPickerView::new_for_ds4_setup(ApiProvider::Deepseek, &Config::default(), None);
assert_eq!(picker.stage, Stage::CustomForm);
assert_eq!(picker.custom_provider_id, "ds4");
assert_eq!(picker.custom_provider_base_url, "http://127.0.0.1:8000/v1");
assert_eq!(picker.custom_provider_model, "deepseek-v4-flash");
assert!(picker.custom_provider_api_key_env.is_empty());
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
provider_id,
base_url,
model,
api_key_env,
}) => {
assert_eq!(provider_id, "ds4");
assert_eq!(base_url, "http://127.0.0.1:8000/v1");
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(api_key_env, None);
}
other => panic!("expected DS4 custom-provider submit event, got {other:?}"),
}
}
#[test]
fn named_custom_provider_selection_preserves_provider_id() {
let mut custom = std::collections::HashMap::new();
custom.insert(
"local_acme".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://localhost:9000/v1".to_string()),
model: Some("acme/code-1".to_string()),
..Default::default()
},
);
let config = Config {
provider: Some("local_acme".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let mut picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let action = picker.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Custom);
assert_eq!(provider_id.as_deref(), Some("local_acme"));
}
other => panic!("expected named custom provider apply, got {other:?}"),
}
}
#[test]
fn named_custom_provider_model_shortcut_preserves_provider_id() {
let mut custom = std::collections::HashMap::new();
custom.insert(
"local_acme".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://localhost:9000/v1".to_string()),
model: Some("acme/code-1".to_string()),
..Default::default()
},
);
let config = Config {
provider: Some("local_acme".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let mut picker = ProviderPickerView::new(ApiProvider::Custom, &config);
let action = picker.handle_key(key(KeyCode::Char('m')));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Custom);
assert_eq!(provider_id.as_deref(), Some("local_acme"));
}
other => panic!("expected named custom provider model shortcut, got {other:?}"),
}
}
#[test]
fn provider_dashboard_row_surfaces_anthropic_wire_protocol() {
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::Anthropic,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.provider_id, "anthropic");
assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
assert_eq!(row.catalog_status, ProviderCatalogStatus::Bundled);
assert!(row.available_model_count >= 3);
}
#[test]
fn provider_dashboard_row_surfaces_openmodel_messages_route() {
let _lock = crate::test_support::lock_test_env();
let _openmodel_key = EnvVarGuard::remove("OPENMODEL_API_KEY");
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::Openmodel,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.provider_id, "openmodel");
assert_eq!(row.display_name, "OpenModel");
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
assert_eq!(row.base_url, crate::config::DEFAULT_OPENMODEL_BASE_URL);
assert_eq!(row.default_route.logical_model, "deepseek-v4-flash");
assert_eq!(row.default_route.wire_model, "deepseek-v4-flash");
assert!(
row.messages
.iter()
.any(|message| message.contains("missing OPENMODEL_API_KEY"))
);
}
#[test]
fn provider_dashboard_row_marks_missing_api_key_as_needs_key() {
let _lock = crate::test_support::lock_test_env();
let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
let config = Config::default();
let row = ProviderDashboardRow::from_config(
ApiProvider::Openrouter,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert_eq!(row.readiness.label(), "missing key");
let hint = row.compact_hint();
assert!(hint.contains("key:not-set"));
assert!(!hint.contains("needs-auth"));
assert!(!hint.contains("auth:missing"));
assert!(
row.messages
.iter()
.any(|message| message.contains("missing OPENROUTER_API_KEY"))
);
}
#[test]
fn modelstudio_family_key_marks_all_variants_configured() {
let _guard = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _ms_key = crate::test_support::EnvVarGuard::remove("MODELSTUDIO_API_KEY");
let _dashscope_key = crate::test_support::EnvVarGuard::remove("DASHSCOPE_API_KEY");
let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
codewhale_secrets::Secrets::auto_detect()
.set("modelstudio-token-plan", "ms-family-key")
.expect("seed family slot");
let config = Config {
provider: Some("deepseek".to_string()),
providers: Some(crate::config::ProvidersConfig {
modelstudio_token_plan: crate::config::ProviderConfig {
auth_mode: Some("api_key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
for variant in [
ApiProvider::ModelstudioTokenPlan,
ApiProvider::ModelstudioTokenPlanAnthropic,
ApiProvider::ModelstudioCodingPlan,
ApiProvider::ModelstudioCodingPlanAnthropic,
] {
let row = ProviderDashboardRow::from_config(variant, ApiProvider::Deepseek, &config);
assert_eq!(
row.auth_status,
ProviderAuthStatus::Configured,
"{variant:?} must resolve the family's one saved key"
);
}
}
#[test]
fn provider_dashboard_row_marks_route_resolver_errors_as_invalid() {
let config = Config {
api_key: Some("deepseek-key".to_string()),
providers: Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
model: Some("anthropic/claude-foreign".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let row = ProviderDashboardRow::from_config(
ApiProvider::Deepseek,
ApiProvider::Deepseek,
&config,
);
assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
assert_eq!(row.readiness, ResolvedProviderReadiness::InvalidRoute);
assert_eq!(row.default_route.wire_model, "unresolved");
assert!(
row.messages
.iter()
.any(|message| message.contains("route validation failed"))
);
}
#[test]
fn provider_dashboard_render_includes_route_protocol_usage_and_base_url() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
api_key: Some("openai-key".to_string()),
base_url: Some("http://localhost:9000/v1".to_string()),
model: Some("custom-model".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new(ApiProvider::Openai, &config);
let rendered = render_text(&picker, 124, 18);
assert!(rendered.contains("key:configured"));
assert!(!rendered.contains("auth:configured"));
assert!(rendered.contains("Route: custom-model"));
assert!(rendered.contains("chat"));
assert!(rendered.contains("cost: unknown"));
assert!(rendered.contains("Endpoint: http://localhost:9000/v1"));
}
#[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,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Ollama);
assert_eq!(provider_id, None);
}
other => panic!("expected ProviderPickerApplied, got {other:?}"),
}
}
#[test]
fn pressing_m_opens_models_for_selected_provider() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Openrouter);
let action = picker.handle_key(key(KeyCode::Char('m')));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Openrouter);
assert_eq!(provider_id, None);
}
other => panic!("expected ProviderPickerOpenModels, got {other:?}"),
}
}
#[test]
fn pressing_uppercase_m_also_opens_models() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let action = picker.handle_key(key(KeyCode::Char('M')));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Deepseek);
assert_eq!(provider_id, None);
}
other => panic!("expected ProviderPickerOpenModels, 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!(picker.rows[picker.selected_idx].is_active);
}
#[test]
fn list_navigation_wraps_between_first_and_last_provider() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.toggle_view();
let first = picker.rows.first().expect("non-empty list").provider;
let last = picker.rows.last().expect("non-empty list").provider;
picker.selected_idx = 0;
picker.handle_key(key(KeyCode::Up));
assert_eq!(picker.selected_provider(), last);
picker.handle_key(key(KeyCode::Down));
assert_eq!(picker.selected_provider(), first);
}
#[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_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_to_provider(&mut picker, ApiProvider::Deepseek);
let action = picker.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Deepseek);
assert_eq!(provider_id, None);
}
other => panic!("expected ProviderPickerApplied, got {other:?}"),
}
}
#[test]
fn new_for_missing_auth_opens_key_entry_focused_on_target() {
let config = Config::default();
let picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::Anthropic,
&config,
None,
)
.expect("Anthropic has a picker row");
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
}
#[test]
fn setup_catalog_shows_all_providers_from_configured_view() {
let config = Config::default();
let picker = ProviderPickerView::new_for_setup(ApiProvider::Deepseek, None, &config, None);
assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.visible_row_count(), picker.rows.len());
let mut listed = picker
.rows
.iter()
.map(|row| row.provider)
.collect::<Vec<_>>();
let mut expected = ApiProvider::catalog().to_vec();
listed.sort_by_key(|provider| provider.as_str());
expected.sort_by_key(|provider| provider.as_str());
assert_eq!(
listed, expected,
"setup must use the canonical provider universe"
);
}
#[test]
fn setup_catalog_focuses_missing_provider_key_entry() {
let _lock = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config::default();
let picker = ProviderPickerView::new_for_setup(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
assert!(picker.api_key_input.is_empty());
}
#[test]
fn onboarding_catalog_focuses_missing_provider_without_leaving_the_list() {
let _lock = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config::default();
let picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);
assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.selected_provider(), ApiProvider::Anthropic);
assert_eq!(
picker.visible_row_count(),
picker.rows.len(),
"onboarding must show the whole provider catalog"
);
}
#[test]
fn first_run_onboarding_starts_with_local_models_and_no_cloud_rows() {
let _lock = crate::test_support::lock_test_env();
let config = Config::default();
let mut picker =
ProviderPickerView::new_for_onboarding(ApiProvider::Deepseek, None, &config, None);
assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.view, ProviderListView::Local);
assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
let visible = picker
.filtered_rows()
.into_iter()
.map(|(_, row)| row.provider)
.collect::<Vec<_>>();
assert!(!visible.is_empty());
assert!(visible.iter().all(|provider| provider.is_self_hosted()));
assert!(visible.contains(&ApiProvider::Ollama));
assert!(visible.contains(&ApiProvider::Sglang));
assert!(visible.contains(&ApiProvider::Vllm));
assert!(!visible.contains(&ApiProvider::OllamaCloud));
assert!(!visible.contains(&ApiProvider::Deepseek));
let rendered = render_text(&picker, 100, 28);
assert!(
rendered.contains("Local models · no cloud key"),
"{rendered}"
);
assert!(rendered.contains("local · no cloud key"), "{rendered}");
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider,
provider_id,
}) => {
assert_eq!(provider, ApiProvider::Ollama);
assert_eq!(provider_id, None);
}
other => panic!("expected keyless local provider apply, got {other:?}"),
}
}
#[test]
fn local_shortcut_filters_cloud_rows_from_the_catalog() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Deepseek),
&config,
None,
);
assert_eq!(picker.view, ProviderListView::Catalog);
assert!(matches!(
picker.handle_key(key(KeyCode::Char('l'))),
ViewAction::None
));
assert_eq!(picker.view, ProviderListView::Local);
assert_eq!(picker.selected_provider(), ApiProvider::Ollama);
assert!(
picker
.filtered_rows()
.into_iter()
.all(|(_, row)| row.provider.is_self_hosted())
);
}
#[test]
fn onboarding_catalog_honors_typed_credentials_for_every_builtin_provider() {
use codewhale_config::provider::CredentialAcquisition;
let _global_env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().expect("isolated provider catalog home");
let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
let _codewhale_home =
EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
let _codex_home = EnvVarGuard::set("CODEX_HOME", home.path().to_string_lossy().as_ref());
let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let mut key_envs = ApiProvider::all()
.iter()
.flat_map(|provider| provider.env_vars().iter().copied())
.collect::<Vec<_>>();
key_envs.sort_unstable();
key_envs.dedup();
let _missing_keys = key_envs
.into_iter()
.map(EnvVarGuard::remove)
.collect::<Vec<_>>();
let config = Config::default();
for provider in ApiProvider::catalog().iter().copied() {
let mut picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(provider),
&config,
None,
);
assert_eq!(picker.selected_provider(), provider, "{provider:?}");
assert_eq!(picker.stage, Stage::List, "{provider:?}");
let action = picker.handle_key(key(KeyCode::Enter));
match provider.credential_help().acquisition {
CredentialAcquisition::ApiKey => {
assert!(matches!(action, ViewAction::None), "{provider:?}");
assert!(
matches!(picker.stage, Stage::KeyEntry | Stage::StepfunBillingRoute),
"{provider:?} entered {:?}",
picker.stage
);
}
CredentialAcquisition::ApiKeyOrOAuth => {
assert_eq!(provider, ApiProvider::Xai, "{provider:?}");
assert!(matches!(action, ViewAction::None), "{provider:?}");
let choices = render_text(&picker, 80, 24);
assert!(choices.contains("API key"), "{choices}");
assert!(choices.contains("device OAuth"), "{choices}");
assert!(matches!(
picker.handle_key(key(KeyCode::Char('1'))),
ViewAction::None
));
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::KeyEntry);
for ch in "violet-".chars() {
picker.handle_key(key(KeyCode::Char(ch)));
}
assert!(picker.handle_paste("otter-key"));
let key_text = "violet-otter-key";
assert_eq!(picker.api_key_input, key_text);
for (width, height) in [(80, 24), (120, 32)] {
let rendered = render_text(&picker, width, height);
assert!(!rendered.contains(key_text), "{width}x{height}: {rendered}");
assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
}
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
provider: ApiProvider::Xai,
provider_id: None,
api_key,
base_url: None,
}) if api_key == key_text
));
let mut oauth = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Xai),
&config,
None,
);
assert!(matches!(
oauth.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert!(matches!(
oauth.handle_key(key(KeyCode::Char('2'))),
ViewAction::None
));
assert!(matches!(
oauth.handle_key(key(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
));
}
CredentialAcquisition::LocalOptional => assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider: applied,
provider_id: None,
}) if applied == provider
)),
CredentialAcquisition::OAuth => {
assert!(matches!(action, ViewAction::None), "{provider:?}");
assert_eq!(picker.stage, Stage::KeyEntry, "{provider:?}");
assert!(picker.handle_paste("fixture-oauth-paste"));
assert!(
picker.api_key_input.is_empty(),
"{provider:?} must reject key paste"
);
}
CredentialAcquisition::Configuration => {
assert_eq!(provider, ApiProvider::Custom);
assert!(matches!(action, ViewAction::None));
assert_eq!(picker.stage, Stage::CustomForm);
assert!(picker.api_key_input.is_empty());
}
}
}
}
#[test]
fn credential_draft_is_masked_and_escape_drops_it_without_persistence() {
let _global_env = crate::test_support::lock_test_env();
let home = tempfile::tempdir().expect("isolated credential draft home");
let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
let _codewhale_home =
EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
let config = Config::default();
let draft = ["violet", "otter", "draft", "7361"].join("-");
let mut picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
)
.expect("OpenRouter key editor");
let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
assert!(matches!(picker.handle_key(ctrl_v), ViewAction::None));
assert!(
picker.api_key_input.is_empty(),
"shortcut must not type `v`"
);
let shifted_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT);
assert!(matches!(picker.handle_key(shifted_v), ViewAction::None));
assert_eq!(
picker.api_key_input, "V",
"shifted credential text is valid"
);
assert!(matches!(
picker.handle_key(key(KeyCode::Backspace)),
ViewAction::None
));
assert!(picker.handle_paste(&draft));
assert_eq!(picker.api_key_input, draft);
for (width, height) in [(80, 24), (120, 32)] {
let rendered = render_text(&picker, width, height);
assert!(!rendered.contains(&draft), "{width}x{height}: {rendered}");
assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
}
assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::List);
assert!(picker.api_key_input.is_empty());
assert_eq!(
std::fs::read_dir(home.path())
.expect("isolated home remains readable")
.count(),
0,
"Esc must not create config or credential-backend files"
);
}
#[test]
fn onboarding_escape_walks_key_entry_back_to_the_list_then_dismisses() {
let _lock = crate::test_support::lock_test_env();
let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
let config = Config::default();
let mut picker = ProviderPickerView::new_for_onboarding(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);
assert_eq!(picker.stage, Stage::List);
picker.enter_key_entry();
assert_eq!(picker.stage, Stage::KeyEntry);
assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert_eq!(
picker.stage,
Stage::List,
"Escape from key entry returns to the provider list"
);
assert!(
matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
),
"Escape from the list dismisses the picker"
);
}
#[test]
fn setup_catalog_uses_setup_title() {
let config = Config::default();
let picker = ProviderPickerView::new_for_setup(ApiProvider::Deepseek, None, &config, None);
let rendered = render_text(&picker, 96, 20);
assert!(rendered.contains("Provider setup"));
}
#[test]
fn setup_catalog_key_entry_uses_setup_reopen_hint() {
let config = Config::default();
let picker = ProviderPickerView::new_for_setup(
ApiProvider::Deepseek,
Some(ApiProvider::Anthropic),
&config,
None,
);
let rendered = render_text(&picker, 96, 20);
assert!(rendered.contains("API key"));
assert!(rendered.contains("/setup provider"));
assert!(!rendered.contains("re-open /provider."));
}
#[test]
fn default_provider_picker_keeps_provider_reopen_hint() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Anthropic);
picker.handle_key(key(KeyCode::Enter));
let rendered = render_text(&picker, 96, 20);
assert!(rendered.contains("API key"));
assert!(rendered.contains("re-open /provider."));
assert!(!rendered.contains("/setup provider"));
}
#[test]
fn setup_catalog_focuses_configured_provider_without_rekeying() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
api_key: Some("openai-key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let picker = ProviderPickerView::new_for_setup(
ApiProvider::Deepseek,
Some(ApiProvider::Openai),
&config,
None,
);
assert_eq!(picker.view, ProviderListView::Catalog);
assert_eq!(picker.stage, Stage::List);
assert_eq!(picker.selected_provider(), ApiProvider::Openai);
}
#[test]
fn new_for_key_entry_with_error_opens_prompt_and_renders_reason() {
let config = Config::default();
let picker = ProviderPickerView::new_for_key_entry_with_error(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"HTTP 401: unauthorized".to_string(),
)
.expect("OpenRouter has a picker row");
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
let rendered = render_text(&picker, 90, 14);
assert!(rendered.contains("Verification failed: HTTP 401: unauthorized"));
}
#[test]
fn new_for_model_pick_after_validation_opens_model_stage() {
let config = Config::default();
let picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"sk-validated".to_string(),
None,
)
.expect("OpenRouter has a picker row");
assert_eq!(picker.stage, Stage::ModelPick);
assert_eq!(picker.selected_provider(), ApiProvider::Openrouter);
assert_eq!(picker.pending_api_key.as_deref(), Some("sk-validated"));
assert!(!picker.model_options.is_empty());
assert!(picker.selected_model.is_some());
}
#[test]
fn model_pick_enter_advances_to_confirm_and_confirm_emits_setup() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"sk-validated".to_string(),
None,
)
.expect("OpenRouter has a picker row");
assert_eq!(picker.stage, Stage::ModelPick);
let action = picker.handle_key(key(KeyCode::Enter));
assert!(matches!(action, ViewAction::None));
assert_eq!(picker.stage, Stage::Confirm);
let selected_model = picker
.selected_model
.clone()
.expect("model selected on confirm");
let action = picker.handle_key(key(KeyCode::Enter));
match action {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
provider,
provider_id,
api_key,
model,
..
}) => {
assert_eq!(provider, ApiProvider::Openrouter);
assert_eq!(provider_id, None);
assert_eq!(api_key, "sk-validated");
assert_eq!(model, selected_model);
}
other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
}
}
#[test]
fn exact_kimi_code_setup_asks_for_plan_and_emits_selected_context_window() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Moonshot,
&config,
None,
"sk-kimi-plan".to_string(),
None,
)
.expect("Moonshot has a picker row");
assert_eq!(picker.stage, Stage::ModelPick);
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::PlanTier);
assert!(matches!(
picker.handle_key(key(KeyCode::Char('2'))),
ViewAction::None
));
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::Confirm);
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
context_window,
model,
..
}) => {
assert_eq!(model, crate::config::KIMI_CODE_K3_MODEL);
assert_eq!(context_window, Some(1_048_576));
}
other => panic!("expected Kimi Code setup confirmation, got {other:?}"),
}
}
#[test]
fn model_pick_and_confirm_esc_backs_out_without_emitting() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"sk-validated".to_string(),
None,
)
.expect("OpenRouter has a picker row");
picker.handle_key(key(KeyCode::Enter));
assert_eq!(picker.stage, Stage::Confirm);
assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::ModelPick);
assert!(matches!(
picker.handle_key(key(KeyCode::Esc)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(picker.api_key_input, "sk-validated");
assert!(picker.pending_api_key.is_some());
}
fn stepfun_config(base_url: Option<&str>) -> Config {
Config {
providers: Some(crate::config::ProvidersConfig {
stepfun: crate::config::ProviderConfig {
base_url: base_url.map(str::to_string),
..Default::default()
},
..Default::default()
}),
..Default::default()
}
}
#[test]
fn stepfun_setup_asks_for_billing_route_before_key_entry() {
let config = stepfun_config(None);
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Stepfun);
assert!(matches!(
picker.handle_key(key(KeyCode::Char('r'))),
ViewAction::None
));
assert_eq!(picker.stage, Stage::StepfunBillingRoute);
assert_eq!(
picker.stepfun_billing_route,
StepfunBillingRoute::PayAsYouGo
);
for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
let rendered = render_text(&picker, w, h);
assert!(
rendered.contains(crate::config::DEFAULT_STEPFUN_BASE_URL)
&& rendered.contains(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
"{w}x{h} must show both StepFun endpoints:\n{rendered}"
);
for (idx, line) in rendered.lines().enumerate() {
assert!(
crate::tui::ui_text::text_display_width(line) <= w as usize,
"{w}x{h} billing-route line {idx} overflows: {line:?}"
);
}
}
assert!(matches!(
picker.handle_key(key(KeyCode::Char('2'))),
ViewAction::None
));
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(
picker.pending_base_url.as_deref(),
Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
);
}
#[test]
fn stepfun_plan_choice_travels_with_the_key_for_validation() {
let config = stepfun_config(None);
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Stepfun);
picker.handle_key(key(KeyCode::Char('r')));
picker.handle_key(key(KeyCode::Char('2')));
picker.handle_key(key(KeyCode::Enter));
for c in "step-plan-key".chars() {
picker.handle_key(key(KeyCode::Char(c)));
}
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
provider,
api_key,
base_url,
..
}) => {
assert_eq!(provider, ApiProvider::Stepfun);
assert_eq!(api_key, "step-plan-key");
assert_eq!(
base_url.as_deref(),
Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
);
}
other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
}
}
#[test]
fn stepfun_confirm_emits_only_the_validated_endpoint() {
let config = stepfun_config(None);
let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Stepfun,
&config,
None,
"step-plan-key".to_string(),
Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
)
.expect("StepFun has a picker row");
assert_eq!(picker.stage, Stage::ModelPick);
picker.handle_key(key(KeyCode::Enter));
assert_eq!(picker.stage, Stage::Confirm);
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
provider,
base_url,
context_window,
..
}) => {
assert_eq!(provider, ApiProvider::Stepfun);
assert_eq!(
base_url.as_deref(),
Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
);
assert_eq!(context_window, None);
}
other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
}
}
#[test]
fn stepfun_custom_base_url_survives_the_wizard_untouched() {
let custom = "https://stepfun.internal.example/v1";
let config = stepfun_config(Some(custom));
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Stepfun);
assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
picker.handle_key(key(KeyCode::Char('r')));
assert_eq!(picker.stage, Stage::KeyEntry);
assert_eq!(picker.pending_base_url, None);
assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
for c in "custom-key".chars() {
picker.handle_key(key(KeyCode::Char(c)));
}
match picker.handle_key(key(KeyCode::Enter)) {
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
base_url, ..
}) => assert_eq!(base_url, None, "custom endpoint must not be rewritten"),
other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
}
}
#[test]
fn stepfun_plan_route_reopens_preselected() {
let config = stepfun_config(Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL));
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Stepfun);
picker.handle_key(key(KeyCode::Char('r')));
assert_eq!(picker.stage, Stage::StepfunBillingRoute);
assert_eq!(picker.stepfun_billing_route, StepfunBillingRoute::StepPlan);
}
#[test]
fn opencode_go_and_zen_read_as_distinct_billing_tracks() {
let go = usage_meter_for(ApiProvider::OpencodeGo);
let zen = usage_meter_for(ApiProvider::OpencodeZen);
assert_ne!(go, zen);
assert!(go.contains("subscription"), "Go label was {go:?}");
assert!(zen.contains("pay-as-you-go"), "Zen label was {zen:?}");
assert_ne!(go, usage_meter_for(ApiProvider::Openrouter));
assert_eq!(
pricing_label(
ApiProvider::OpencodeGo,
Some(&PricingSku::Token {
input_per_mtok: Some(1.0),
output_per_mtok: Some(2.0),
}),
),
go
);
}
#[test]
fn guided_flow_stages_render_at_80x24_and_120x32() {
let config = Config::default();
let model_pick = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"sk-validated-key".to_string(),
None,
)
.expect("OpenRouter has a picker row");
let mut confirm = ProviderPickerView::new_for_model_pick_after_validation(
ApiProvider::Deepseek,
ApiProvider::Openrouter,
&config,
None,
"sk-validated-key".to_string(),
None,
)
.expect("OpenRouter has a picker row");
confirm.handle_key(key(KeyCode::Enter));
assert_eq!(confirm.stage, Stage::Confirm);
for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
let model_text = render_text(&model_pick, w, h);
assert!(
model_text.contains("Default model") || model_text.contains("default model"),
"{w}x{h} model pick missing title:\n{model_text}"
);
assert!(
model_text.contains("continue") || model_text.contains("Enter"),
"{w}x{h} model pick missing continue affordance:\n{model_text}"
);
for (idx, line) in model_text.lines().enumerate() {
assert!(
crate::tui::ui_text::text_display_width(line) <= w as usize,
"{w}x{h} model pick line {idx} overflows: {line:?}"
);
}
let confirm_text = render_text(&confirm, w, h);
assert!(
confirm_text.contains("Confirm"),
"{w}x{h} confirm missing title:\n{confirm_text}"
);
assert!(
confirm_text.contains("Provider:") || confirm_text.contains("OpenRouter"),
"{w}x{h} confirm missing provider summary:\n{confirm_text}"
);
assert!(
confirm_text.contains("Model:") || confirm_text.contains("model"),
"{w}x{h} confirm missing model summary:\n{confirm_text}"
);
assert!(
!confirm_text.contains("sk-validated-key"),
"{w}x{h} confirm leaked raw key:\n{confirm_text}"
);
for (idx, line) in confirm_text.lines().enumerate() {
assert!(
crate::tui::ui_text::text_display_width(line) <= w as usize,
"{w}x{h} confirm line {idx} overflows: {line:?}"
);
}
}
}
#[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 configured_api_key_editors_acknowledge_saved_credentials_across_providers() {
for (provider, config, secret) in [
(
ApiProvider::Zai,
Config {
providers: Some(crate::config::ProvidersConfig {
zai: crate::config::ProviderConfig {
api_key: Some("stored-zai-key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
},
"stored-zai-key",
),
(
ApiProvider::Openrouter,
Config {
providers: Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("stored-openrouter-key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
},
"stored-openrouter-key",
),
] {
let mut picker = ProviderPickerView::new(provider, &config);
move_to_provider(&mut picker, provider);
picker.handle_key(key(KeyCode::Char('r')));
let rendered = render_text(&picker, 100, 20);
assert!(
rendered.contains("Saved credential configured"),
"{provider:?}:\n{rendered}"
);
assert!(rendered.contains("stored credential"), "{rendered}");
assert!(rendered.contains("replace saved key"), "{rendered}");
assert!(rendered.contains("keep current key"), "{rendered}");
assert!(!rendered.contains("paste key here"), "{rendered}");
assert!(!rendered.contains(secret), "{rendered}");
}
}
#[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, 14);
assert!(rendered.contains("Enter"), "rendered: {rendered}");
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);
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,
provider_id,
api_key,
base_url,
}) => {
assert_eq!(provider, ApiProvider::Novita);
assert_eq!(provider_id, None);
assert_eq!(api_key, "novita-key");
assert_eq!(base_url, None);
}
other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
}
}
#[test]
fn openai_codex_key_entry_is_oauth_only() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::OpenaiCodex,
&config,
None,
)
.expect("OpenAI Codex has a picker row");
assert_eq!(picker.stage, Stage::KeyEntry);
let rendered = render_text(&picker, 96, 20);
assert!(rendered.contains("OAuth login"), "{rendered}");
assert!(rendered.contains("no token is stored here"), "{rendered}");
assert!(!rendered.contains("save & switch"));
assert!(!rendered.contains("(paste key here)"));
assert!(!rendered.contains("Credentials:"));
assert!(picker.handle_paste("codex-token"));
for c in "codex-token".chars() {
picker.handle_key(key(KeyCode::Char(c)));
}
assert!(picker.api_key_input.is_empty());
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::ExternalConsentChoice);
let choices = render_text(&picker, 100, 20);
assert!(choices.contains("Disabled (default)"), "{choices}");
assert!(choices.contains("Read-only"), "{choices}");
assert!(choices.contains("Managed (unavailable)"), "{choices}");
picker.handle_key(key(KeyCode::Char('2')));
picker.handle_key(key(KeyCode::Enter));
assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
let confirm = render_text(&picker, 120, 22);
assert!(confirm.contains("Owning CLI: Codex CLI"), "{confirm}");
assert!(confirm.contains("Exact resolved path:"), "{confirm}");
assert!(confirm.contains("no refresh, identity-provider or discovery requests"));
assert!(confirm.contains("normal requests to the selected provider"));
assert!(confirm.contains("external-revoke --provider openai-codex"));
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentConfirmed {
provider: ApiProvider::OpenaiCodex,
consent_provider: codewhale_config::ProviderKind::OpenaiCodex,
source: codewhale_config::ExternalCredentialSource::CodexCli,
..
})
));
}
#[test]
fn external_consent_surface_uses_the_selected_locale() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::OpenaiCodex,
&config,
None,
)
.expect("OpenAI Codex has a picker row")
.with_locale(crate::localization::Locale::ZhHans);
picker.handle_key(key(KeyCode::Enter));
let choices = render_text(&picker, 100, 20);
let compact = choices
.chars()
.filter(|ch| !ch.is_whitespace())
.collect::<String>();
assert!(compact.contains("外部凭据访问"), "{choices}");
assert!(compact.contains("禁用(默认)"), "{choices}");
assert!(compact.contains("托管(不可用)"), "{choices}");
}
#[test]
fn xai_auth_choice_keeps_api_key_device_oauth_and_external_reuse_distinct() {
let config = Config::default();
let mut picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::Xai,
&config,
None,
)
.expect("xAI has a picker row");
assert_eq!(picker.stage, Stage::XaiAuthChoice);
let rendered = render_text(&picker, 96, 20);
assert!(rendered.contains("xAI API key"));
assert!(rendered.contains("Native device OAuth"));
assert!(rendered.contains("Codewhale-owned storage"));
picker.handle_key(key(KeyCode::Char('2')));
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
));
let mut external = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::Xai,
&config,
None,
)
.expect("xAI has a picker row");
assert!(matches!(
external.handle_key(key(KeyCode::Char('e'))),
ViewAction::None
));
assert_eq!(external.stage, Stage::ExternalConsentChoice);
let rendered = render_text(&external, 100, 20);
assert!(rendered.contains("Managed (unavailable)"), "{rendered}");
}
#[test]
fn xai_auth_choice_uses_the_selected_locale() {
let config = Config::default();
let picker = ProviderPickerView::new_for_missing_auth(
ApiProvider::Deepseek,
ApiProvider::Xai,
&config,
None,
)
.expect("xAI has a picker row")
.with_locale(crate::localization::Locale::ZhHans);
let rendered = render_text(&picker, 100, 24);
let compact = rendered
.chars()
.filter(|ch| !ch.is_whitespace())
.collect::<String>();
for translated in [
"xAI身份验证",
"请选择一个明确的凭据来源",
"xAIAPI密钥",
"原生设备OAuth",
] {
assert!(compact.contains(translated), "{translated}: {rendered}");
}
assert!(!rendered.contains("Choose one explicit credential source"));
assert!(!rendered.contains("Native device OAuth"));
}
#[test]
fn xai_auth_status_distinguishes_oauth_from_api_key_auth() {
let oauth_config = crate::config::ProviderConfig {
auth_mode: Some("oauth".to_string()),
..Default::default()
};
assert_eq!(
xai_oauth_status(Some(&oauth_config), false),
Some(ProviderAuthStatus::OAuthMissing)
);
assert_eq!(
xai_oauth_status(Some(&oauth_config), true),
Some(ProviderAuthStatus::OAuthReady)
);
assert_eq!(xai_oauth_status(None, true), None);
assert_eq!(xai_oauth_status(None, false), None);
let fallback_key = crate::config::ProviderConfig {
auth_mode: Some("oauth".to_string()),
api_key: Some("xai-api-key".to_string()),
..Default::default()
};
assert_eq!(
xai_oauth_status(Some(&fallback_key), false),
Some(ProviderAuthStatus::Configured)
);
for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
let placeholder = crate::config::ProviderConfig {
auth_mode: Some("oauth".to_string()),
api_key: Some(sentinel.to_string()),
..Default::default()
};
assert_eq!(
xai_oauth_status(Some(&placeholder), false),
Some(ProviderAuthStatus::OAuthMissing)
);
}
}
#[test]
fn inactive_external_consents_are_visible_without_io_and_never_enter_routing_inventory() {
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("external consent fixtures");
let codex_path = temp.path().join("codex-auth.json");
let grok_path = temp.path().join("grok-auth.json");
let codex_raw = "codex-external-file-must-not-be-read";
let grok_raw = "grok-external-file-must-not-be-read";
std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
std::fs::write(&grok_path, grok_raw).expect("write Grok trap");
let owned_home = temp.path().join("codewhale-owned");
let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
let _codex_path =
crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
let _grok_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
let _codex_access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
let _legacy_codex_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let config = Config {
provider: Some(ApiProvider::Deepseek.as_str().to_string()),
providers: Some(crate::config::ProvidersConfig {
openai_codex: crate::config::ProviderConfig {
auth_mode: Some("oauth".to_string()),
external_credentials: Some(
codewhale_config::ExternalCredentialConsentToml::read_only(
codewhale_config::ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
codex_path.clone(),
),
),
..Default::default()
},
xai: crate::config::ProviderConfig {
auth_mode: Some("oauth".to_string()),
external_credentials: Some(
codewhale_config::ExternalCredentialConsentToml::read_only(
codewhale_config::ProviderKind::Xai,
codewhale_config::ExternalCredentialSource::GrokCli,
grok_path.clone(),
),
),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
crate::external_credentials::reset_side_effect_trap();
assert!(!has_api_key_for(&config, ApiProvider::OpenaiCodex));
assert!(!has_api_key_for(&config, ApiProvider::Xai));
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
for provider in [ApiProvider::OpenaiCodex, ApiProvider::Xai] {
let index = picker
.rows
.iter()
.position(|row| row.provider == provider)
.expect("consented provider row");
let row = &picker.rows[index];
assert_eq!(row.credential_state, CredentialState::ExternalConsent);
assert_eq!(row.auth_status, ProviderAuthStatus::OAuthConsented);
let structural = row
.external_credential_status
.as_ref()
.expect("external status");
assert_eq!(structural.access.as_str(), "read_only");
assert_eq!(structural.route_state, "dormant");
assert!(structural.revoke_command.contains(provider.as_str()));
assert_eq!(
row.readiness,
ResolvedProviderReadiness::ExternalConsentPendingSelection
);
assert!(!row.readiness.can_attempt());
picker.selected_idx = index;
let visible = render_text(&picker, 140, 32);
assert!(visible.contains("External: access=read_only"), "{visible}");
assert!(visible.contains("Owner/path:"), "{visible}");
assert!(
visible.contains("revoke: codewhale auth external-revoke"),
"{visible}"
);
assert!(
picker.selected_has_key(),
"selecting {provider:?} should activate the consented route before checking it"
);
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
provider: selected,
..
}) if selected == provider
));
}
assert!(matches!(
picker.handle_key(key(KeyCode::Char('x'))),
ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
provider: ApiProvider::Xai
})
));
let inventory = crate::model_inventory::ModelInventory::from_config(&config);
assert!(
inventory.candidates.iter().all(|candidate| !matches!(
candidate.provider,
ApiProvider::OpenaiCodex | ApiProvider::Xai
)),
"dormant external-only routes must not reach auto-routing inventory"
);
assert_eq!(
crate::route_billing::for_route(&config, ApiProvider::Xai),
crate::route_billing::BillingPresentation::Metered
);
assert_eq!(
crate::external_credentials::side_effect_trap_counts(),
(0, 0),
"picker, readiness, billing, and model inventory must not inspect inactive external files"
);
assert_eq!(
std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
codex_raw
);
assert_eq!(
std::fs::read_to_string(&grok_path).expect("Grok trap unchanged"),
grok_raw
);
assert!(!owned_home.join("credentials/xai-auth.json").exists());
}
#[test]
fn kimi_cli_token_is_never_auto_enabled_without_explicit_legacy_auth_mode() {
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("Kimi import fixture root");
let kimi_home = temp.path().join("kimi-code");
std::fs::create_dir_all(kimi_home.join("credentials"))
.expect("Kimi import credential directory");
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_secs_f64()
+ 3600.0;
std::fs::write(
kimi_home.join("credentials/kimi-code.json"),
serde_json::json!({
"access_token": "unexpired-user-owned-token",
"refresh_token": "must-not-be-used",
"expires_at": expires_at,
})
.to_string(),
)
.expect("write Kimi import fixture");
let _kimi_home = crate::test_support::EnvVarGuard::set(
"KIMI_CODE_HOME",
kimi_home.to_str().expect("utf8 path"),
);
let _moonshot_key = crate::test_support::EnvVarGuard::remove("MOONSHOT_API_KEY");
let _kimi_key = crate::test_support::EnvVarGuard::remove("KIMI_API_KEY");
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &Config::default());
move_to_provider(&mut picker, ApiProvider::Moonshot);
let row = &picker.rows[picker.selected_idx];
assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
assert_eq!(row.credential_state, CredentialState::MissingKey);
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(
picker.stage,
Stage::KeyEntry,
"a stray Kimi CLI credential must lead to API-key setup, not import activation"
);
}
#[test]
fn explicit_legacy_kimi_import_is_unavailable_and_routes_to_api_key_setup() {
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("Kimi import fixture root");
let kimi_home = temp.path().join("kimi-code");
std::fs::create_dir_all(kimi_home.join("credentials"))
.expect("Kimi import credential directory");
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_secs_f64()
+ 3600.0;
std::fs::write(
kimi_home.join("credentials/kimi-code.json"),
serde_json::json!({
"access_token": "unexpired-user-owned-token",
"refresh_token": "must-not-be-used",
"expires_at": expires_at,
})
.to_string(),
)
.expect("write Kimi import fixture");
let _kimi_home = crate::test_support::EnvVarGuard::set(
"KIMI_CODE_HOME",
kimi_home.to_str().expect("utf8 path"),
);
let config = Config {
providers: Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
auth_mode: Some("kimi_oauth".to_string()),
..Default::default()
},
..Default::default()
}),
..Default::default()
};
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Moonshot);
let row = &picker.rows[picker.selected_idx];
assert_eq!(
row.auth_status,
ProviderAuthStatus::ImportedTokenUnavailable
);
assert_eq!(row.credential_state, CredentialState::MissingKey);
assert_eq!(row.base_url, crate::config::DEFAULT_KIMI_CODE_BASE_URL);
assert_eq!(
row.default_route.logical_model,
crate::config::DEFAULT_KIMI_CODE_MODEL
);
assert_eq!(row.usage_meter, "usage: Kimi API key required");
assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
assert!(matches!(
picker.handle_key(key(KeyCode::Enter)),
ViewAction::None
));
assert_eq!(picker.stage, Stage::KeyEntry);
}
#[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_emits_dismiss_memory() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let action = picker.handle_key(key(KeyCode::Esc));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
));
}
#[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_catalog_render_shows_selected_provider_details() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.toggle_view();
let rendered = render_text(&picker, 80, 23);
assert!(rendered.contains("DeepSeek *"));
assert!(rendered.contains("Details"));
assert!(rendered.contains("Route:"));
}
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
#[test]
fn provider_picker_is_usable_and_opaque_at_blocker_sizes() {
use crate::tui::views::ViewStack;
const SENTINEL: &str = "\u{2592}"; let config = Config::default();
let active = ProviderPickerView::new(ApiProvider::Deepseek, &config).rows[0].provider;
for (w, h) in BLOCKER_SIZES {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol(SENTINEL);
}
}
let mut stack = ViewStack::new();
stack.push(ProviderPickerView::new(active, &config));
stack.render(area, &mut buf);
let rows: Vec<String> = (0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
let text = rows.join("\n");
for label in ["move", "jump", "edit key", "models", "cancel"] {
assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
}
assert!(
text.contains("apply") || text.contains("set key"),
"{w}x{h}: missing Enter action label"
);
assert!(
!text.contains(SENTINEL),
"{w}x{h}: background bleed-through into modal surface"
);
assert_eq!(
buf[(w / 2, h / 2)].bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
for (y, row) in rows.iter().enumerate() {
assert!(
unicode_width::UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
}
}
}
#[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::SELECTION_BG
})
.count();
assert!(
highlighted_cells >= 32,
"selected provider row should use a visible continuous highlight"
);
}
#[test]
fn search_footer_shows_two_stage_esc_as_a_single_hint() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.query = "deep".to_string();
let area = Rect::new(0, 0, 100, 24);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
let text = area
.positions()
.map(|position| buf[position].symbol())
.collect::<String>();
assert_eq!(
text.matches(" Esc ").count(),
1,
"search footer must not duplicate the Esc key: {text}"
);
assert!(text.contains("clear / cancel"), "{text}");
}
#[test]
fn esc_reports_browsing_context_and_reopen_restores_it() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
picker.handle_key(key(KeyCode::Char('a')));
picker.handle_key(key(KeyCode::Down));
let remembered_id = picker.rows[picker.selected_idx].provider_id.clone();
let action = picker.handle_key(key(KeyCode::Esc));
let ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
catalog_view,
selected_provider_id,
}) = action
else {
panic!("expected ProviderPickerDismissed");
};
assert!(catalog_view);
assert_eq!(
selected_provider_id.as_deref(),
Some(remembered_id.as_str())
);
let memory = crate::tui::app::ProviderPickerMemory {
catalog_view,
selected_provider_id,
};
let reopened = ProviderPickerView::new_with_runtime_status_and_memory(
ApiProvider::Deepseek,
&config,
None,
Some(&memory),
);
assert_eq!(reopened.view, ProviderListView::Catalog);
assert_eq!(
reopened.rows[reopened.selected_idx].provider_id,
remembered_id
);
}
}