返回 CodeWhale
provider_picker.rs
根目录 / crates / tui / src / tui / provider_picker.rs
1 //! `/provider` picker modal — pick a provider (DeepSeek / NVIDIA NIM /
2 //! hosted providers / self-hosted providers) and, if it lacks credentials, type the API key
3 //! inline before completing the switch (#52).
4 //!
5 //! The picker is intentionally a single modal with guided stages (#3875):
6 //!
7 //! 1. **List** — pick a provider; each row shows the active provider arrow
8 //! and an "API key configured" / "needs API key" hint. Enter on a
9 //! configured provider applies the switch immediately
10 //! ([`ViewEvent::ProviderPickerApplied`]). Enter on an un-configured one
11 //! transitions the same modal into the key-entry state.
12 //! 2. **Key entry** — masked input box pre-filled with the provider's
13 //! canonical env-var name as a hint. Enter submits
14 //! [`ViewEvent::ProviderPickerApiKeySubmitted`] for live validation.
15 //! Failed verification reopens this stage with the provider error and
16 //! never persists the rejected secret.
17 //! 3. **Model pick** — after a key validates, choose a default model from
18 //! the provider catalog (provider default pre-selected).
19 //! 4. **Confirm** — summary of provider + masked key + model. Enter emits
20 //! [`ViewEvent::ProviderPickerSetupConfirmed`], which the UI handler
21 //! persists (comment-preserving) before switching.
22 //! 5. **Custom form** — a named OpenAI-compatible endpoint form. Enter submits
23 //! [`ViewEvent::ProviderPickerCustomProviderSubmitted`], which persists a
24 //! `[providers.<name>]` table without storing raw secrets.
25 //!
26 //! Pressing Esc backs out one stage at a time; from the list it closes the
27 //! modal without changes.
28
29 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
30 use ratatui::{
31 buffer::Buffer,
32 layout::{Constraint, Direction, Layout, Position, Rect},
33 style::{Modifier, Style},
34 text::{Line, Span},
35 widgets::{Block, Borders, Paragraph, Widget, Wrap},
36 };
37
38 use crate::config::{
39 Config, ProviderIdentity, ProviderKind, base_url_uses_local_host, provider_is_configured,
40 };
41 use crate::core::ops::ProviderRuntimeStatus;
42 use crate::model_profile::{
43 SupportState, resolved_capability_profile, resolved_capability_profile_for_route,
44 };
45 use crate::models_dev_live::{self, ModelsDevFreshness};
46 use crate::provider_lake::{catalog_model_count_for_provider, catalog_offering_for_model};
47 use crate::provider_readiness::{
48 CredentialState, ProviderReadinessSnapshot, ProviderRouteIdentity, ResolvedProviderReadiness,
49 credential_state_for_provider, route_identity_for_model,
50 };
51 use crate::reasoning_preference::ReasoningEffort;
52 use crate::tui::list_nav::{self, Motion};
53 use crate::tui::menu_style;
54 use crate::tui::views::{
55 ActionHint, EmptyState, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent,
56 centered_modal_area, render_modal_footer, render_modal_surface, render_underwater_surface,
57 };
58 use codewhale_config::catalog::{CatalogOffering, CatalogSnapshot};
59 use codewhale_config::descriptors::{
60 ProviderDescriptor, bundled_provider_descriptors, provider_descriptor,
61 };
62 use codewhale_config::provider::{CredentialAcquisition, WireFormat};
63 use codewhale_config::route::{PricingSku, RequestProtocol};
64 use codewhale_localization::{Locale, MessageId, tr};
65 use codewhale_palette as palette;
66 use serde_json::Value;
67 use std::borrow::Cow;
68 use std::cell::RefCell;
69 use std::sync::OnceLock;
70
71 use codewhale_config::descriptors::defaults::{
72 DS4_BASE_URL, DS4_DEFAULT_MODEL, LM_STUDIO_BASE_URL,
73 };
74
75 const DS4_PROVIDER_ID: &str = "ds4";
76 const LM_STUDIO_PROVIDER_ID: &str = "lm_studio";
77 /// Rows a PageUp/PageDown travels in the provider lists. Both views are
78 /// short modal surfaces; a page is a readable jump, not a screenful measured
79 /// at paint time — the same rule as `fleet_detail`'s page constant.
80 const PROVIDER_PAGE: usize = 10;
81
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 enum Stage {
84 List,
85 /// Explicit xAI acquisition choice. xAI supports both an API key and the
86 /// Codewhale-owned device OAuth flow; neither path may impersonate the other.
87 XaiAuthChoice,
88 /// Official ChatGPT plan sign-in; imported CLI credentials cannot grant
89 /// plan permission to this route.
90 ChatgptAuthChoice,
91 KeyEntry,
92 /// Explicit disabled/read-only/managed external-credential policy choice.
93 ExternalConsentChoice,
94 /// Full owner/path/side-effect disclosure before a read grant is saved.
95 ExternalConsentConfirm,
96 /// Explicit confirmation before revoking external access; revocation
97 /// clears only Codewhale-owned consent state (#5772).
98 ExternalConsentRevokeConfirm,
99 /// Default model pick after a key has been live-validated (#3875).
100 ModelPick,
101 /// Kimi Code membership plan selection for the exact `api.kimi.com` route.
102 PlanTier,
103 /// StepFun pay-as-you-go vs Step Plan endpoint choice, asked before key
104 /// entry so the selected route is the one that gets live-validated (#4526).
105 StepfunBillingRoute,
106 /// Confirmation summary before any secret or model is persisted (#3875).
107 Confirm,
108 CustomForm,
109 }
110
111 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112 enum ExternalConsentChoice {
113 Disabled,
114 ReadOnly,
115 ManagedUnavailable,
116 }
117
118 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119 enum XaiAuthChoice {
120 ApiKey,
121 DeviceOAuth,
122 }
123
124 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
125 enum KimiCodePlanTier {
126 Safe262k,
127 OneMillion,
128 }
129
130 /// StepFun's two billing tracks. They are separate endpoints, not separate
131 /// keys, so the setup wizard has to pick one before a key can be validated.
132 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
133 enum StepfunBillingRoute {
134 PayAsYouGo,
135 StepPlan,
136 }
137
138 impl StepfunBillingRoute {
139 fn base_url(self) -> &'static str {
140 match self {
141 Self::PayAsYouGo => crate::config::DEFAULT_STEPFUN_BASE_URL,
142 Self::StepPlan => crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL,
143 }
144 }
145 }
146
147 /// Whether the StepFun billing-route choice applies to `base_url`.
148 ///
149 /// Only the two endpoints Codewhale can classify are offered. A hand-edited
150 /// endpoint (regional proxy, gateway, anything unrecognized) is a deliberate
151 /// user choice, so the stage is skipped rather than silently rewriting it.
152 fn stepfun_route_is_selectable(provider: ProviderKind, base_url: &str) -> bool {
153 provider == ProviderKind::Stepfun
154 && matches!(
155 crate::pricing::billing_surface_for_route(provider, Some(base_url)),
156 Some(crate::pricing::STEPFUN_PAYG_BILLING_SURFACE)
157 | Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
158 )
159 }
160
161 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
162 enum CustomProviderField {
163 Name,
164 BaseUrl,
165 Model,
166 ApiKeyEnv,
167 }
168
169 /// Which subset of `rows` the list stage shows (#3830). `Configured` is the
170 /// normal `/provider` default; first-run onboarding opens `Catalog` so a user
171 /// can pick a hosted API or a local runtime immediately. `A` still exposes the
172 /// full catalog and `L` filters to local routes.
173 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
174 enum ProviderListView {
175 Configured,
176 Catalog,
177 Local,
178 }
179
180 pub struct ProviderPickerView {
181 route_config: Config,
182 rows: Vec<ProviderDashboardRow>,
183 selected_idx: usize,
184 stage: Stage,
185 view: ProviderListView,
186 setup_mode: bool,
187 /// First-run/recovery keeps the canonical provider engine but removes
188 /// the advanced management hotkeys from the decision surface. They remain
189 /// available from `/provider` after onboarding.
190 onboarding_mode: bool,
191 query: String,
192 /// Explicit search keeps provider names from invoking legacy letter actions.
193 search_mode: bool,
194 api_key_input: String,
195 /// An error surfaced after a failed key verification, shown inline
196 /// in the key-entry stage. Cleared when the user edits the input.
197 key_entry_error: Option<String>,
198 locale: Locale,
199 xai_auth_choice: XaiAuthChoice,
200 external_consent_choice: ExternalConsentChoice,
201 /// Where Esc returns from the revoke confirmation. Revocation is reachable
202 /// both from the list (`x`) and from the policy choice, and "back" has to
203 /// mean the step the user actually came from.
204 external_revoke_return: Stage,
205 /// Validated key held only in memory until the confirm stage persists it.
206 pending_api_key: Option<String>,
207 /// Set by the first key press or click. A background discovery (the
208 /// first-run Ollama probe) must not switch provider and close the picker
209 /// under someone who has started choosing or typing a key.
210 interacted: bool,
211 /// Catalog models offered during the model-pick stage.
212 model_options: Vec<String>,
213 model_selected_idx: usize,
214 /// Model chosen on the model-pick stage (and shown on confirm).
215 selected_model: Option<String>,
216 selected_context_window: Option<u32>,
217 kimi_code_plan_tier: KimiCodePlanTier,
218 stepfun_billing_route: StepfunBillingRoute,
219 /// Endpoint chosen in the setup wizard, carried unpersisted through key
220 /// validation and only written on confirm (#4526).
221 pending_base_url: Option<String>,
222 custom_provider_field: CustomProviderField,
223 custom_provider_id: String,
224 custom_provider_base_url: String,
225 custom_provider_model: String,
226 custom_provider_api_key_env: String,
227 /// Bundled descriptor the custom form was prefilled from, so the form can
228 /// show that host's credential console, docs and guidance (#6616). `None`
229 /// for a hand-entered host and the other prefilled local routes.
230 custom_provider_descriptor: Option<&'static ProviderDescriptor>,
231 /// Pointer geometry for the two-pane picker (Slice D): provider-strip
232 /// rows on the left, model rows on the right/under, recorded during
233 /// render like the consent hitboxes below.
234 list_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
235 model_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
236 consent_row_hitboxes: RefCell<Vec<(Rect, usize)>>,
237 choice_row_hitboxes: RefCell<Vec<(Rect, char)>>,
238 detail_action_hitbox: RefCell<Option<Rect>>,
239 catalog_action_hitbox: RefCell<Option<Rect>>,
240 catalog_action_hovered: bool,
241 detail_action_hovered: bool,
242 hovered_choice: Option<char>,
243 last_choice_mouse_selected: Option<(Stage, char)>,
244 /// Pointer hover positions. Advisory only — hover never moves the
245 /// keyboard selection; it renders with the shared
246 /// [`crate::tui::menu_style::hovered_row_style`] primitive.
247 hovered_list_idx: Option<usize>,
248 hovered_model_idx: Option<usize>,
249 hovered_consent_idx: Option<usize>,
250 /// Last clicked row per pane for single-click-select /
251 /// double-click-activate rhythm (mirrors the model picker).
252 last_list_mouse_selected: Option<usize>,
253 last_model_mouse_selected: Option<usize>,
254 }
255
256 #[derive(Debug, Clone, PartialEq, Eq)]
257 pub struct ProviderDashboardRow {
258 pub provider: ProviderKind,
259 pub(crate) identity: Option<ProviderIdentity>,
260 pub provider_id: String,
261 pub display_name: String,
262 pub kind: String,
263 pub base_url: String,
264 pub auth_status: ProviderAuthStatus,
265 pub catalog_status: ProviderCatalogStatus,
266 pub supported_protocols: Vec<String>,
267 pub available_model_count: usize,
268 pub default_route: ProviderDefaultRoute,
269 pub request_concurrency: ProviderRequestConcurrencySummary,
270 pub reasoning: ProviderReasoningSummary,
271 pub capabilities: ProviderCapabilityBadges,
272 pub model_origin: ProviderModelOrigin,
273 pub(crate) readiness: ResolvedProviderReadiness,
274 billing_presentation: crate::route_billing::BillingPresentation,
275 pub maturity: ProviderMaturity,
276 pub messages: Vec<String>,
277 external_credential_status: Option<codewhale_config::ExternalCredentialConsentStatus>,
278 pub is_active: bool,
279 has_key: bool,
280 /// Human-readable name of the place this row's credential resolved from,
281 /// or "not found". Ported from pi-mono's `AuthResult.source`; a label
282 /// only, never secret material.
283 pub(crate) credential_source: String,
284 credential_state: CredentialState,
285 route_identity: Option<ProviderRouteIdentity>,
286 route_ok: bool,
287 /// Whether this provider should appear in the default `/provider`
288 /// manager view (#3830) without the user explicitly browsing the full
289 /// catalog: the active provider, one with working credentials/OAuth, a
290 /// custom provider entry, or any provider with a non-default
291 /// `[providers.<name>]` table entry. A self-hosted provider type
292 /// (Ollama/Sglang/Vllm) does *not* auto-qualify just because its auth is
293 /// optional — that would clutter the default view with every untouched
294 /// local-provider slot.
295 pub is_configured: bool,
296 }
297
298 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
299 pub enum ProviderAuthStatus {
300 Configured,
301 Missing,
302 NoAuth,
303 Optional,
304 OAuthReady,
305 OAuthConsented,
306 OAuthMissing,
307 ImportedTokenUnavailable,
308 Local,
309 Legacy,
310 }
311
312 #[derive(Debug, Clone, PartialEq, Eq)]
313 pub enum ProviderCatalogStatus {
314 Bundled,
315 DefaultOnly,
316 Legacy,
317 }
318
319 #[derive(Debug, Clone, PartialEq, Eq)]
320 pub struct ProviderDefaultRoute {
321 pub logical_model: String,
322 pub wire_model: String,
323 }
324
325 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
326 pub struct ProviderRequestConcurrencySummary {
327 pub limit: Option<usize>,
328 pub active: Option<usize>,
329 }
330
331 /// How battle-tested a provider integration is, independent of whether the
332 /// user has credentials configured (which `ProviderReadiness` already tracks).
333 /// Kept intentionally minimal — the only two honest states today are an
334 /// experimental integration and a supported one (#2984).
335 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
336 pub enum ProviderMaturity {
337 Experimental,
338 Supported,
339 }
340
341 impl ProviderMaturity {
342 /// Maturity is seeded from a small table keyed by provider. Only the
343 /// OpenAI Codex bridge is experimental today; everything else is supported.
344 fn for_provider(provider: ProviderKind) -> Self {
345 match provider {
346 ProviderKind::OpenaiCodex => Self::Experimental,
347 _ => Self::Supported,
348 }
349 }
350
351 /// Compact tag for the picker hint. Returns `None` when the integration is
352 /// supported so the common case stays noise-free (#2984).
353 fn tag(self) -> Option<&'static str> {
354 match self {
355 Self::Experimental => Some("experimental"),
356 Self::Supported => None,
357 }
358 }
359 }
360
361 /// Where the row's current model came from, so the dashboard can distinguish a
362 /// provider default from a saved override or a custom pass-through id (#3083).
363 /// Live-catalog/static origins are not yet distinguishable here; they arrive
364 /// with the #3385 live-fetch layer.
365 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
366 pub enum ProviderModelOrigin {
367 Default,
368 Saved,
369 Custom,
370 }
371
372 impl ProviderModelOrigin {
373 fn for_provider(provider: ProviderKind, has_saved_model: bool) -> Self {
374 if has_saved_model {
375 Self::Saved
376 } else if provider == ProviderKind::Custom {
377 Self::Custom
378 } else {
379 Self::Default
380 }
381 }
382
383 fn label(self) -> &'static str {
384 match self {
385 Self::Default => "default",
386 Self::Saved => "saved",
387 Self::Custom => "custom",
388 }
389 }
390 }
391
392 /// Capability + metadata badges projected from the resolved capability profile
393 /// (#3083). Tri-state so "unknown" stays distinct from "unsupported"; metadata
394 /// is `None` when not resolvable. Reasoning is tracked separately in
395 /// [`ProviderReasoningSummary`].
396 #[derive(Debug, Clone, PartialEq, Eq)]
397 pub struct ProviderCapabilityBadges {
398 pub context_window: Option<u32>,
399 /// Source receipt for the route-effective context-window badge.
400 pub context_window_source: Option<String>,
401 pub max_output: Option<u32>,
402 pub tools: SupportState,
403 pub structured: SupportState,
404 pub streaming: SupportState,
405 pub cache: SupportState,
406 pub vision: SupportState,
407 }
408
409 impl ProviderCapabilityBadges {
410 fn for_route(provider: ProviderKind, wire_model: &str) -> Self {
411 let cap = catalog_offering_for_model(provider, wire_model).map_or_else(
412 || resolved_capability_profile(provider, wire_model),
413 |offering| {
414 let route_offering = offering.to_offering();
415 resolved_capability_profile_for_route(
416 provider,
417 wire_model,
418 route_offering.capabilities,
419 route_offering.limits,
420 )
421 },
422 );
423 Self {
424 context_window: cap.context_window,
425 context_window_source: None,
426 max_output: cap.max_output,
427 tools: cap.native_tool_calls,
428 structured: cap.structured_output,
429 streaming: cap.streaming,
430 cache: cap.prompt_caching,
431 vision: cap.image_input,
432 }
433 }
434
435 fn unknown() -> Self {
436 Self {
437 context_window: None,
438 context_window_source: None,
439 max_output: None,
440 tools: SupportState::Unknown,
441 structured: SupportState::Unknown,
442 streaming: SupportState::Unknown,
443 cache: SupportState::Unknown,
444 vision: SupportState::Unknown,
445 }
446 }
447
448 /// Compact, never-fabricating badge cluster. Metadata and each capability
449 /// render `?` when unknown rather than being silently dropped.
450 fn label(&self) -> String {
451 format!(
452 "ctx:{}({}) out:{} tools:{} json:{} stream:{} cache:{} vision:{}",
453 humanize_token_count(self.context_window),
454 self.context_window_source.as_deref().unwrap_or("?"),
455 humanize_token_count(self.max_output),
456 support_glyph(self.tools),
457 support_glyph(self.structured),
458 support_glyph(self.streaming),
459 support_glyph(self.cache),
460 support_glyph(self.vision),
461 )
462 }
463 }
464
465 fn support_glyph(state: SupportState) -> &'static str {
466 match state {
467 SupportState::Supported => "y",
468 SupportState::Unsupported => "n",
469 SupportState::Unknown => "?",
470 }
471 }
472
473 fn humanize_token_count(value: Option<u32>) -> String {
474 match value {
475 None => "?".to_string(),
476 Some(v) if v >= 1_000_000 && v % 1_000_000 == 0 => format!("{}M", v / 1_000_000),
477 Some(v) if v >= 1_000_000 => format!("{:.1}M", f64::from(v) / 1_000_000.0),
478 Some(v) if v >= 1_000 => format!("{}K", v / 1_000),
479 Some(v) => v.to_string(),
480 }
481 }
482
483 #[derive(Debug, Clone, PartialEq, Eq)]
484 pub struct ProviderReasoningSummary {
485 pub support: ProviderReasoningSupport,
486 pub controls: Vec<String>,
487 pub stream_visibility: ProviderReasoningStreamVisibility,
488 pub selected_control: Option<String>,
489 }
490
491 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
492 pub enum ProviderReasoningSupport {
493 Supported,
494 Unsupported,
495 Unknown,
496 }
497
498 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
499 pub enum ProviderReasoningStreamVisibility {
500 StructuredThinking,
501 InlineTags,
502 SummaryOnly,
503 NotExposed,
504 Unknown,
505 }
506
507 impl ProviderDashboardRow {
508 #[cfg(test)]
509 fn from_config(provider: ProviderKind, active: ProviderKind, config: &Config) -> Self {
510 Self::from_config_with_runtime_status(provider, active, config, None)
511 }
512
513 fn from_config_with_runtime_status(
514 provider: ProviderKind,
515 active: ProviderKind,
516 config: &Config,
517 runtime_status: Option<&ProviderRuntimeStatus>,
518 ) -> Self {
519 Self::from_config_with_provider_id(
520 provider,
521 active,
522 config,
523 None,
524 config.provider.as_deref(),
525 runtime_status,
526 )
527 }
528
529 fn from_custom_config_with_runtime_status(
530 provider_id: &str,
531 active: ProviderKind,
532 config: &Config,
533 runtime_status: Option<&ProviderRuntimeStatus>,
534 ) -> Self {
535 Self::from_config_with_provider_id(
536 ProviderKind::Custom,
537 active,
538 config,
539 Some(provider_id),
540 config.provider.as_deref(),
541 runtime_status,
542 )
543 }
544
545 fn from_config_with_provider_id(
546 provider: ProviderKind,
547 _active: ProviderKind,
548 config: &Config,
549 provider_id_override: Option<&str>,
550 _active_provider_id: Option<&str>,
551 runtime_status: Option<&ProviderRuntimeStatus>,
552 ) -> Self {
553 let presentation = (provider != ProviderKind::Custom)
554 .then(|| {
555 codewhale_config::descriptors::compatibility_for_id(
556 provider_id_override.unwrap_or(provider.as_str()),
557 )
558 })
559 .flatten();
560 let provider_id = provider_id_override
561 .unwrap_or(provider.as_str())
562 .to_string();
563 let active = config.active_provider_identity().ok();
564 let admitted = active
565 .as_ref()
566 .filter(|identity| {
567 provider_id_override.is_none()
568 && identity.key.as_str() == provider_id
569 && identity.provider == provider
570 })
571 .cloned()
572 .map(Ok)
573 .unwrap_or_else(|| config.resolve_provider_pin_identity(&provider_id))
574 .and_then(|identity| {
575 (identity.provider == provider)
576 .then_some(identity)
577 .ok_or_else(|| {
578 "configured provider identity conflicts with this catalog row".to_string()
579 })
580 });
581 let identity = match admitted {
582 Ok(identity) => identity,
583 Err(error) => {
584 // Broken/manual entries stay visible. These facts cannot grant a
585 // credential lookup, health reuse, or activation capability.
586 let configured = config.providers.as_ref().and_then(|providers| {
587 if provider == ProviderKind::Custom {
588 providers.custom_provider_config(&provider_id)
589 } else {
590 presentation.and_then(
591 |row| codewhale_config::provider_config_table!(@read providers, row.id),
592 )
593 }
594 });
595 let is_active = config.provider.as_deref() == Some(provider_id.as_str());
596 return Self {
597 provider,
598 identity: None,
599 provider_id: provider_id.clone(),
600 // The blank `Custom` catalog slot is not a configured route,
601 // so admission fails by design; it keeps its catalog label.
602 display_name: presentation
603 .or_else(|| {
604 (provider == ProviderKind::Custom && configured.is_none())
605 .then(|| {
606 codewhale_config::descriptors::compatibility_for_id(
607 &provider_id,
608 )
609 })
610 .flatten()
611 })
612 .map(|row| row.label.to_string())
613 .unwrap_or_else(|| format!("{provider_id} (custom)")),
614 kind: configured
615 .and_then(|entry| entry.kind.clone())
616 .unwrap_or_else(|| "unavailable".into()),
617 base_url: configured
618 .and_then(|entry| entry.base_url.clone())
619 .or_else(|| presentation.map(|row| row.base_url.to_string()))
620 .unwrap_or_default(),
621 auth_status: ProviderAuthStatus::Legacy,
622 catalog_status: ProviderCatalogStatus::Legacy,
623 supported_protocols: Vec::new(),
624 available_model_count: 0,
625 default_route: ProviderDefaultRoute {
626 logical_model: configured
627 .and_then(|entry| entry.model.clone())
628 .or_else(|| presentation.map(|row| row.default_model.to_string()))
629 .unwrap_or_default(),
630 wire_model: "unresolved".into(),
631 },
632 request_concurrency: ProviderRequestConcurrencySummary {
633 limit: None,
634 active: None,
635 },
636 reasoning: ProviderReasoningSummary {
637 support: ProviderReasoningSupport::Unknown,
638 controls: Vec::new(),
639 stream_visibility: ProviderReasoningStreamVisibility::Unknown,
640 selected_control: None,
641 },
642 capabilities: ProviderCapabilityBadges::unknown(),
643 model_origin: ProviderModelOrigin::for_provider(
644 provider,
645 configured.is_some_and(|entry| entry.model.is_some()),
646 ),
647 readiness: ResolvedProviderReadiness::Legacy,
648 billing_presentation: crate::route_billing::BillingPresentation::Unknown,
649 maturity: ProviderMaturity::for_provider(provider),
650 messages: vec![format!("provider admission failed: {error}")],
651 external_credential_status: None,
652 is_active,
653 has_key: false,
654 credential_source: "not checked (unadmitted route)".into(),
655 credential_state: CredentialState::Legacy,
656 route_identity: None,
657 route_ok: false,
658 is_configured: is_active || configured.is_some(),
659 };
660 }
661 };
662 let is_active = active.as_ref() == Some(&identity);
663 let display_name = identity
664 .compatibility()
665 .map(|row| row.label.to_string())
666 .unwrap_or_else(|| format!("{provider_id} (custom)"));
667 // Capture product presentation without activating an inactive row.
668 let billing_presentation = crate::route_billing::for_route(config, &identity);
669 let configured = config.provider_config_for(&identity);
670 let configured_base_url = configured
671 .and_then(|entry| entry.base_url.as_deref())
672 .map(str::trim)
673 .filter(|value| !value.is_empty())
674 .map(str::to_string);
675 let uses_kimi_imported_token = provider == ProviderKind::Moonshot
676 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token);
677 let configured_base_url = configured_base_url.or_else(|| {
678 uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string())
679 });
680 let explicitly_configured_model = configured
681 .and_then(|entry| entry.model.as_deref())
682 .map(str::trim)
683 .filter(|value| !value.is_empty())
684 .map(str::to_string);
685 let has_configured_model = explicitly_configured_model.is_some();
686 let configured_model = explicitly_configured_model.or_else(|| {
687 uses_kimi_imported_token.then(|| crate::config::DEFAULT_KIMI_CODE_MODEL.to_string())
688 });
689 let model_origin = ProviderModelOrigin::for_provider(provider, has_configured_model);
690 // One sourced resolution per row: the picker must be able to say WHERE
691 // it looked, not just that it found nothing (pi-mono's `AuthResult`
692 // source, ported in `crate::credentials`).
693 let credential_resolution = crate::config::resolve_credential_source(config, &identity);
694 let has_key = if provider == ProviderKind::Custom {
695 custom_provider_has_auth(configured)
696 } else {
697 credential_resolution.is_present()
698 };
699 // #5772: normal picker copy never carries an external file path —
700 // the exact path is disclosed only inside the explicit reuse
701 // confirmation.
702 let credential_source = match &credential_resolution.source {
703 crate::credentials::CredentialSource::ExternalGrant { cli, .. } => {
704 format!("{cli} credentials (read-only)")
705 }
706 // Name the signed-in subscription account (label only) so two
707 // accounts on one machine are told apart before a limit is hit.
708 // The resolver read it with the sign-in it just proved usable;
709 // the picker opens no credential file of its own. Known limit:
710 // that structural read (`usable_sign_in`, formerly
711 // `credentials_present`) still runs synchronously when a row is
712 // built, as it did before #6715; naming the account adds no read.
713 crate::credentials::CredentialSource::OAuth {
714 account: Some(account),
715 ..
716 } => format!("{} · {account}", credential_resolution.source.label()),
717 other => other.label().into_owned(),
718 };
719 let credential_state = credential_state_for_provider(config, &identity);
720 let auth_mode = config.auth_mode_for_provider(&identity);
721 let no_auth = crate::config::auth_mode_disables_api_key(auth_mode.as_deref());
722 let api_key_required = crate::config::auth_mode_requires_api_key(auth_mode.as_deref());
723 let official_endpoint = !config.provider_uses_custom_endpoint(&identity);
724 let auth_base_url = config.base_url_for_route(&identity);
725 let xai_oauth_ready = provider == ProviderKind::Xai
726 && official_endpoint
727 && crate::oauth::credentials_valid(crate::oauth::OAuthProvider::Xai, config);
728 let auth_status = if credential_state == CredentialState::ExternalConsent {
729 ProviderAuthStatus::OAuthConsented
730 } else {
731 auth_status_for(
732 provider,
733 &auth_base_url,
734 has_key,
735 configured,
736 no_auth,
737 api_key_required,
738 official_endpoint,
739 xai_oauth_ready,
740 )
741 };
742 // Slice D: cost lives at the model level (per-model $/mtok in the
743 // models pane and the model-pick stage), never on the provider row.
744 // Auth guidance that used to ride the provider meter (e.g. the Kimi
745 // imported-token hint) travels on `messages` via
746 // `missing_auth_message` below instead.
747 let request_concurrency = ProviderRequestConcurrencySummary::for_row(
748 &identity,
749 config,
750 runtime_status,
751 is_active,
752 );
753 let available_model_count = catalog_model_count_for_provider(provider);
754 let catalog_status = if available_model_count == 0 {
755 ProviderCatalogStatus::DefaultOnly
756 } else {
757 ProviderCatalogStatus::Bundled
758 };
759 let mut messages = Vec::new();
760 // Use the same route-effective resolver as the active runtime. In
761 // particular, Kimi Code's bare K3 model has a conservative 262K
762 // membership-plan baseline (or an explicit configured override), not
763 // the generic catalog's unknown-model fallback.
764 let route_base = config.base_url_for_route(&identity);
765 let declared_default = configured_model
766 .as_deref()
767 .or_else(|| {
768 is_active
769 .then_some(config.default_text_model.as_deref())
770 .flatten()
771 })
772 .filter(|model| {
773 crate::provider_lake::configured_model_for_route(
774 config,
775 provider,
776 &provider_id,
777 &route_base,
778 model,
779 )
780 .is_some()
781 });
782 let route = if let Some(model) = declared_default {
783 crate::route_runtime::resolve_declared_model_candidate(
784 provider,
785 &provider_id,
786 model,
787 &route_base,
788 config.context_window_for_provider_config(&identity),
789 config.model_context_windows_for(&identity),
790 config.custom_models.as_deref().unwrap_or_default(),
791 )
792 } else {
793 // Browsing a provider is a snapshot projection, not execution
794 // admission. Actual local requests still require an explicit tag
795 // or a fresh endpoint-owned roster in resolve_runtime_route.
796 crate::route_runtime::resolve_route_candidate_with_context_metadata(
797 provider,
798 configured_model
799 .as_deref()
800 .or_else(|| identity.compatibility().map(|row| row.default_model)),
801 None,
802 Some(route_base.clone()),
803 config.context_window_for_provider_config(&identity),
804 config.model_context_windows_for(&identity),
805 None,
806 )
807 };
808 let (
809 base_url,
810 supported_protocols,
811 default_route,
812 route_ok,
813 route_context_window,
814 route_context_window_source,
815 ) = match route {
816 Ok(resolution) => {
817 let candidate = resolution.candidate;
818 if !candidate.validation().messages.is_empty() {
819 messages.extend(candidate.validation().messages.clone());
820 }
821 (
822 candidate.endpoint().base_url.clone(),
823 vec![protocol_label(candidate.protocol()).to_string()],
824 ProviderDefaultRoute {
825 logical_model: candidate.logical_model().raw().to_string(),
826 wire_model: candidate.wire_model_id().as_str().to_string(),
827 },
828 candidate.validation().ok,
829 Some(resolution.context_window.tokens),
830 Some(resolution.context_window.source.label().to_string()),
831 )
832 }
833 Err(error) => {
834 messages.push(format!("route validation failed: {error}"));
835 (
836 configured_base_url.unwrap_or_else(|| {
837 identity
838 .compatibility()
839 .map(|row| row.base_url)
840 .unwrap_or_default()
841 .to_string()
842 }),
843 vec![
844 provider
845 .provider()
846 .wire_policy()
847 .fixed()
848 .map(|protocol| protocol_label(protocol).to_string())
849 .unwrap_or_else(|| "model-aware".to_string()),
850 ],
851 ProviderDefaultRoute {
852 logical_model: configured_model.unwrap_or_else(|| "invalid".to_string()),
853 wire_model: "unresolved".to_string(),
854 },
855 false,
856 None,
857 None,
858 )
859 }
860 };
861
862 if matches!(
863 auth_status,
864 ProviderAuthStatus::Missing
865 | ProviderAuthStatus::OAuthMissing
866 | ProviderAuthStatus::ImportedTokenUnavailable
867 ) {
868 messages.push(missing_auth_message(
869 provider,
870 configured,
871 &provider_id,
872 &credential_resolution,
873 ));
874 }
875 if catalog_status == ProviderCatalogStatus::DefaultOnly {
876 messages.push("catalog snapshot missing; using provider default".to_string());
877 }
878
879 let route_identity =
880 route_identity_for_model(config, &identity, &default_route.logical_model);
881 let readiness = readiness_for(
882 &route_identity,
883 credential_state,
884 route_ok,
885 &ProviderReadinessSnapshot::default(),
886 );
887 let reasoning =
888 ProviderReasoningSummary::for_route(&identity, &base_url, &default_route, config);
889 let mut capabilities =
890 ProviderCapabilityBadges::for_route(provider, &default_route.wire_model);
891 if let Some(context_window) = route_context_window {
892 capabilities.context_window = Some(context_window);
893 }
894 capabilities.context_window_source = route_context_window_source;
895 if let Some(declared) = crate::provider_lake::configured_model_for_route(
896 config,
897 provider,
898 &provider_id,
899 &base_url,
900 &default_route.wire_model,
901 ) {
902 capabilities = ProviderCapabilityBadges::unknown();
903 capabilities.context_window = declared
904 .limit
905 .as_ref()
906 .and_then(|limit| limit.context)
907 .and_then(|value| u32::try_from(value).ok());
908 capabilities.max_output = declared
909 .limit
910 .as_ref()
911 .and_then(|limit| limit.output)
912 .and_then(|value| u32::try_from(value).ok());
913 capabilities.context_window_source = Some("user declared (unverified)".into());
914 messages.push("Model metadata is user declared; availability and capabilities have not been verified.".into());
915 }
916 let available_model_count = crate::provider_lake::configured_catalog_models_for_route(
917 config,
918 provider,
919 &provider_id,
920 &base_url,
921 )
922 .len();
923 // #5772: the status projection needs an ambient candidate path to
924 // report `ambient_path_changed`, so resolving it for a provider the
925 // user never consented to would derive (and then render) another CLI's
926 // credential location during ordinary browsing. Ask for it only once a
927 // consent record exists.
928 let external_credential_status = configured
929 .and_then(|entry| entry.external_credentials.as_ref())
930 .and_then(|_| config.external_credential_consent_status(&identity));
931
932 Self {
933 provider,
934 identity: Some(identity),
935 billing_presentation,
936 provider_id,
937 display_name,
938 kind: configured
939 .and_then(|entry| entry.kind.as_deref())
940 .filter(|value| !value.trim().is_empty())
941 .map(str::to_string)
942 .unwrap_or_else(|| format!("{provider:?}")),
943 base_url,
944 auth_status,
945 catalog_status,
946 supported_protocols,
947 available_model_count,
948 default_route,
949 request_concurrency,
950 reasoning,
951 capabilities,
952 model_origin,
953 readiness,
954 maturity: ProviderMaturity::for_provider(provider),
955 messages,
956 external_credential_status,
957 is_active,
958 has_key,
959 credential_source,
960 credential_state,
961 route_identity: Some(route_identity),
962 route_ok,
963 is_configured: provider_is_configured(
964 provider,
965 is_active,
966 has_key,
967 configured,
968 provider == ProviderKind::Custom && provider_id_override.is_some(),
969 ),
970 }
971 }
972
973 /// Every fact the Details pane shows for this provider, as one string.
974 ///
975 /// Test-facing: the list row is deliberately short now, so assertions
976 /// about a provider's capabilities, reasoning, concurrency or self-hosted
977 /// posture belong against the surface that actually shows them.
978 #[cfg(test)]
979 fn detail_facts(&self) -> String {
980 let self_hosted =
981 if crate::config::provider_route_is_keyless_self_hosted(self.provider, &self.base_url)
982 || matches!(
983 self.auth_status,
984 ProviderAuthStatus::Local | ProviderAuthStatus::Optional
985 )
986 {
987 " (self-hosted)"
988 } else {
989 ""
990 };
991 format!(
992 "{} | Endpoint: {}{} | Protocol: {} | Capabilities: {} | Reasoning: {}{}",
993 self.detail_state_line(),
994 self.base_url,
995 self_hosted,
996 self.supported_protocols.join("+"),
997 self.capabilities.label(),
998 self.reasoning.label(),
999 self.request_concurrency
1000 .label()
1001 .map(|label| format!(" | {label}"))
1002 .unwrap_or_default(),
1003 )
1004 }
1005
1006 /// The Details pane's state line: everything the short list row no longer
1007 /// repeats. One owner so the renderer and the tests cannot disagree about
1008 /// what a provider's state reads as.
1009 fn detail_state_line(&self) -> String {
1010 let auth = match self.auth_status {
1011 ProviderAuthStatus::Missing | ProviderAuthStatus::Configured => String::new(),
1012 status => format!(" | {}", status.label()),
1013 };
1014 format!(
1015 "{}{auth} | {}{}",
1016 self.readiness.label(),
1017 self.catalog_label(),
1018 self.maturity
1019 .tag()
1020 .map(|tag| format!(" | {tag}"))
1021 .unwrap_or_default()
1022 )
1023 }
1024
1025 /// One short state per row. The Details pane beside the list already
1026 /// carries the credential, endpoint, protocol, capabilities, reasoning and
1027 /// model facts, so repeating them on every row of a forty-provider list
1028 /// was pure noise — founder live-test: "the missing key etc etc stuff is
1029 /// soooooo busy and it doesn't need to be at all". `compact_hint` keeps
1030 /// the full pipe-delimited form for surfaces that have no Details pane.
1031 fn list_row_hint(&self, view: ProviderListView) -> String {
1032 match view {
1033 // `readiness` already reads as prose ("key saved · not checked",
1034 // "missing key"); `auth_status` said the same thing again in
1035 // machine spelling ("key:configured", "key:not-set").
1036 ProviderListView::Configured => self.readiness.label().to_string(),
1037 ProviderListView::Catalog => {
1038 // A row you cannot use yet says what it needs, once. The
1039 // bundled-model count beside it only repeated itself down a
1040 // fifty-row list; the Details pane still carries it.
1041 if self.is_custom_placeholder() {
1042 return "needs endpoint".to_string();
1043 }
1044 match self.readiness {
1045 ResolvedProviderReadiness::MissingKey => return "needs key".to_string(),
1046 ResolvedProviderReadiness::MissingLogin => return "needs sign-in".to_string(),
1047 _ => {}
1048 }
1049 let catalog = self.catalog_label();
1050 if catalog.is_empty() {
1051 self.readiness.label().to_string()
1052 } else {
1053 format!("{} · {catalog}", self.readiness.label())
1054 }
1055 }
1056 ProviderListView::Local => format!("local · {}", self.default_route.logical_model),
1057 }
1058 }
1059
1060 /// The blank `Custom` slot: Enter opens the endpoint form (see
1061 /// `activate_selected_row`), so it is neither a broken route nor legacy.
1062 fn is_custom_placeholder(&self) -> bool {
1063 self.provider == ProviderKind::Custom
1064 && !self.is_configured
1065 && provider_descriptor(&self.provider_id).is_none()
1066 }
1067
1068 fn catalog_label(&self) -> String {
1069 match self.catalog_status {
1070 ProviderCatalogStatus::Bundled => format!("{} bundled", self.available_model_count),
1071 ProviderCatalogStatus::DefaultOnly => "default-only".to_string(),
1072 ProviderCatalogStatus::Legacy => "legacy".to_string(),
1073 }
1074 }
1075
1076 /// Cross-field search (#3830 P1, #4141): match a query against the provider
1077 /// name (display name, provider id, kind, provider key), the base URL, and
1078 /// the default route's display model name and wire model id. Matching the
1079 /// route means a model name or wire id surfaces the provider that serves it,
1080 /// keeping this picker consistent with the model picker's cross-field search
1081 /// (`model_row_matches_query`).
1082 fn matches_query(&self, query: &str) -> bool {
1083 let query = query.trim().to_ascii_lowercase();
1084 if query.is_empty() {
1085 return true;
1086 }
1087 self.display_name.to_ascii_lowercase().contains(&query)
1088 || self.provider_id.to_ascii_lowercase().contains(&query)
1089 || self.kind.to_ascii_lowercase().contains(&query)
1090 || self.base_url.to_ascii_lowercase().contains(&query)
1091 || self.provider.as_str().to_ascii_lowercase().contains(&query)
1092 || self
1093 .default_route
1094 .logical_model
1095 .to_ascii_lowercase()
1096 .contains(&query)
1097 || self
1098 .default_route
1099 .wire_model
1100 .to_ascii_lowercase()
1101 .contains(&query)
1102 }
1103 }
1104
1105 impl ProviderRequestConcurrencySummary {
1106 fn for_row(
1107 identity: &ProviderIdentity,
1108 config: &Config,
1109 runtime_status: Option<&ProviderRuntimeStatus>,
1110 is_active: bool,
1111 ) -> Self {
1112 let mut summary = Self {
1113 limit: config.provider_max_concurrency(identity),
1114 active: None,
1115 };
1116 if is_active
1117 && let Some(status) = runtime_status
1118 && status.provider == identity.provider
1119 {
1120 summary.limit = status.request_concurrency_limit;
1121 summary.active = Some(status.active_provider_requests);
1122 }
1123 summary
1124 }
1125
1126 fn label(self) -> Option<String> {
1127 match (self.limit, self.active) {
1128 (Some(limit), Some(active)) => Some(format!("req:{active}/{limit}")),
1129 (Some(limit), None) => Some(format!("req:cap {limit}")),
1130 (None, Some(active)) if active > 0 => Some(format!("req:{active}/uncapped")),
1131 _ => None,
1132 }
1133 }
1134 }
1135
1136 impl ProviderReasoningSummary {
1137 fn for_route(
1138 identity: &ProviderIdentity,
1139 base_url: &str,
1140 route: &ProviderDefaultRoute,
1141 config: &Config,
1142 ) -> Self {
1143 let provider = identity.provider;
1144 if provider == ProviderKind::OpenaiCodex {
1145 return Self {
1146 support: ProviderReasoningSupport::Supported,
1147 controls: codex_reasoning_controls(),
1148 stream_visibility: ProviderReasoningStreamVisibility::StructuredThinking,
1149 selected_control: selected_reasoning_control(provider, config),
1150 };
1151 }
1152
1153 // The bare `k3` ID is deliberately not listed as a generic Moonshot
1154 // model. Kimi Code owns this reasoning contract only at its exact
1155 // membership-plan endpoint, so surface the capability before key
1156 // entry without attributing it to neighboring Moonshot routes.
1157 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, &route.wire_model) {
1158 return Self {
1159 support: ProviderReasoningSupport::Supported,
1160 controls: vec!["low".to_string(), "high".to_string(), "max".to_string()],
1161 stream_visibility: configured_or_default_stream_visibility(
1162 identity,
1163 config,
1164 ProviderReasoningSupport::Supported,
1165 ),
1166 selected_control: selected_reasoning_control(provider, config),
1167 };
1168 }
1169
1170 if let Some(offering) = reasoning_catalog_offering(provider, route) {
1171 let support = match offering.reasoning {
1172 Some(true) => ProviderReasoningSupport::Supported,
1173 Some(false) => ProviderReasoningSupport::Unsupported,
1174 None => ProviderReasoningSupport::Unknown,
1175 };
1176 let controls = reasoning_controls_from_options(&offering.reasoning_options);
1177 return Self {
1178 support,
1179 controls,
1180 stream_visibility: configured_or_default_stream_visibility(
1181 identity, config, support,
1182 ),
1183 selected_control: selected_reasoning_control(provider, config),
1184 };
1185 }
1186
1187 Self::unknown(identity, config)
1188 }
1189
1190 fn unknown(identity: &ProviderIdentity, config: &Config) -> Self {
1191 let provider = identity.provider;
1192 Self {
1193 support: ProviderReasoningSupport::Unknown,
1194 controls: Vec::new(),
1195 stream_visibility: configured_or_default_stream_visibility(
1196 identity,
1197 config,
1198 ProviderReasoningSupport::Unknown,
1199 ),
1200 selected_control: selected_reasoning_control(provider, config),
1201 }
1202 }
1203
1204 fn label(&self) -> String {
1205 let support = match self.support {
1206 ProviderReasoningSupport::Supported if !self.controls.is_empty() => {
1207 format!("reasoning:{}", self.controls.join("/"))
1208 }
1209 ProviderReasoningSupport::Supported => "reasoning:yes".to_string(),
1210 ProviderReasoningSupport::Unsupported => "reasoning:no".to_string(),
1211 ProviderReasoningSupport::Unknown => "reasoning:unknown".to_string(),
1212 };
1213 let mut parts = vec![
1214 support,
1215 format!("stream:{}", self.stream_visibility.label()),
1216 ];
1217 if let Some(selected) = &self.selected_control {
1218 parts.push(format!("ctrl:{selected}"));
1219 }
1220 parts.join(" ")
1221 }
1222 }
1223
1224 impl ProviderReasoningStreamVisibility {
1225 fn label(self) -> &'static str {
1226 match self {
1227 Self::StructuredThinking => "structured",
1228 Self::InlineTags => "inline-tags",
1229 Self::SummaryOnly => "summary-only",
1230 Self::NotExposed => "not-exposed",
1231 Self::Unknown => "unknown",
1232 }
1233 }
1234 }
1235
1236 impl ProviderAuthStatus {
1237 fn label(self) -> &'static str {
1238 match self {
1239 Self::Configured => "key:configured",
1240 Self::Missing => "key:not-set",
1241 Self::NoAuth => "auth:none",
1242 Self::Optional => "key:optional",
1243 Self::OAuthReady => "auth:oauth-ready",
1244 Self::OAuthConsented => "auth:oauth-consented-select-to-check",
1245 Self::OAuthMissing => "auth:oauth-missing",
1246 Self::ImportedTokenUnavailable => "auth:imported-token-unavailable",
1247 Self::Local => "local",
1248 Self::Legacy => "legacy",
1249 }
1250 }
1251 }
1252
1253 /// Compact Models.dev freshness chip for the provider picker chrome (#4139).
1254 fn catalog_freshness_title_suffix() -> &'static str {
1255 catalog_freshness_title_suffix_for(models_dev_live::status().freshness)
1256 }
1257
1258 fn catalog_freshness_title_suffix_for(freshness: ModelsDevFreshness) -> &'static str {
1259 match freshness {
1260 ModelsDevFreshness::Stale => " · cached catalog",
1261 // A failed optional refresh keeps prior or bundled rows available.
1262 // Say what the picker is using instead of implying the catalog broke.
1263 ModelsDevFreshness::Failed => " · refresh failed; catalog available",
1264 ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
1265 }
1266 }
1267
1268 fn reasoning_catalog_offering(
1269 provider: ProviderKind,
1270 route: &ProviderDefaultRoute,
1271 ) -> Option<&'static CatalogOffering> {
1272 let provider_id = provider.as_str();
1273 bundled_reasoning_catalog()
1274 .offerings
1275 .iter()
1276 .find(|offering| {
1277 offering.provider == provider_id
1278 && offering
1279 .wire_model_id
1280 .eq_ignore_ascii_case(&route.wire_model)
1281 })
1282 }
1283
1284 fn bundled_reasoning_catalog() -> &'static CatalogSnapshot {
1285 static CATALOG: OnceLock<CatalogSnapshot> = OnceLock::new();
1286 CATALOG.get_or_init(|| CatalogSnapshot {
1287 // Source reasoning descriptors from the single bundled Models.dev
1288 // snapshot (the same data #3385's catalog layer uses) rather than a
1289 // hand-maintained per-row seed, so provider reasoning rows (GLM-5.2,
1290 // etc.) cannot drift from the catalog and every bundled provider with
1291 // reasoning facts is covered, not just GLM.
1292 offerings: codewhale_config::catalog::bundled_catalog_offerings(),
1293 })
1294 }
1295
1296 fn codex_reasoning_controls() -> Vec<String> {
1297 [
1298 ReasoningEffort::Low,
1299 ReasoningEffort::Medium,
1300 ReasoningEffort::High,
1301 ReasoningEffort::Max,
1302 ]
1303 .iter()
1304 .map(|effort| {
1305 effort
1306 .display_label_for_provider(ProviderKind::OpenaiCodex)
1307 .to_string()
1308 })
1309 .collect()
1310 }
1311
1312 fn reasoning_controls_from_options(options: &[Value]) -> Vec<String> {
1313 let mut controls = Vec::new();
1314 for option in options {
1315 collect_reasoning_controls(option, &mut controls);
1316 }
1317 controls
1318 }
1319
1320 fn collect_reasoning_controls(value: &Value, controls: &mut Vec<String>) {
1321 match value {
1322 Value::String(text) => push_reasoning_control(controls, text),
1323 Value::Array(items) => {
1324 for item in items {
1325 collect_reasoning_controls(item, controls);
1326 }
1327 }
1328 Value::Object(map) => {
1329 if let Some(values) = map.get("values") {
1330 collect_reasoning_controls(values, controls);
1331 }
1332 }
1333 _ => {}
1334 }
1335 }
1336
1337 fn push_reasoning_control(controls: &mut Vec<String>, value: &str) {
1338 let normalized = value.trim();
1339 if normalized.is_empty() || controls.iter().any(|item| item == normalized) {
1340 return;
1341 }
1342 controls.push(normalized.to_string());
1343 }
1344
1345 fn selected_reasoning_control(provider: ProviderKind, config: &Config) -> Option<String> {
1346 let effort = ReasoningEffort::from_setting_for_provider(config.reasoning_effort()?, provider);
1347 Some(effort.display_label_for_provider(provider).to_string())
1348 }
1349
1350 fn configured_or_default_stream_visibility(
1351 identity: &ProviderIdentity,
1352 config: &Config,
1353 support: ProviderReasoningSupport,
1354 ) -> ProviderReasoningStreamVisibility {
1355 if let Some(configured) = config
1356 .provider_config_for(identity)
1357 .and_then(|entry| entry.reasoning_stream_style.as_deref())
1358 && let Some(visibility) = parse_reasoning_stream_visibility(configured)
1359 {
1360 return visibility;
1361 }
1362
1363 match support {
1364 ProviderReasoningSupport::Unsupported => ProviderReasoningStreamVisibility::NotExposed,
1365 ProviderReasoningSupport::Unknown => ProviderReasoningStreamVisibility::Unknown,
1366 ProviderReasoningSupport::Supported => {
1367 default_reasoning_stream_visibility(identity.provider)
1368 }
1369 }
1370 }
1371
1372 fn parse_reasoning_stream_visibility(value: &str) -> Option<ProviderReasoningStreamVisibility> {
1373 match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
1374 "separate_field" | "separate" | "field" | "structured" | "structured_thinking" => {
1375 Some(ProviderReasoningStreamVisibility::StructuredThinking)
1376 }
1377 "inline_tags" | "inline" | "think_tags" | "thinking_tags" => {
1378 Some(ProviderReasoningStreamVisibility::InlineTags)
1379 }
1380 "summary" | "summary_only" => Some(ProviderReasoningStreamVisibility::SummaryOnly),
1381 "none" | "text" | "disabled" | "off" | "not_exposed" => {
1382 Some(ProviderReasoningStreamVisibility::NotExposed)
1383 }
1384 _ => None,
1385 }
1386 }
1387
1388 fn default_reasoning_stream_visibility(
1389 provider: ProviderKind,
1390 ) -> ProviderReasoningStreamVisibility {
1391 match provider {
1392 ProviderKind::OpenaiCodex
1393 | ProviderKind::Deepseek
1394 | ProviderKind::NvidiaNim
1395 | ProviderKind::Openrouter
1396 | ProviderKind::XiaomiMimo
1397 | ProviderKind::Novita
1398 | ProviderKind::Fireworks
1399 | ProviderKind::Siliconflow
1400 | ProviderKind::SiliconflowCN
1401 | ProviderKind::Volcengine
1402 | ProviderKind::Arcee
1403 | ProviderKind::Minimax
1404 | ProviderKind::MinimaxAnthropic
1405 | ProviderKind::Sglang
1406 | ProviderKind::Vllm
1407 | ProviderKind::Zai
1408 | ProviderKind::Xai
1409 // Model Studio surfaces reasoning as structured Thinking on both
1410 // dialects: `delta.reasoning_content` on the OpenAI-compatible
1411 // routes, thinking blocks on the Anthropic-compatible routes.
1412 | ProviderKind::ModelstudioTokenPlan
1413 | ProviderKind::ModelstudioTokenPlanAnthropic
1414 | ProviderKind::ModelstudioCodingPlan
1415 | ProviderKind::ModelstudioCodingPlanAnthropic
1416 | ProviderKind::Moonshot => ProviderReasoningStreamVisibility::StructuredThinking,
1417 _ => ProviderReasoningStreamVisibility::Unknown,
1418 }
1419 }
1420
1421 #[allow(clippy::too_many_arguments)]
1422 fn auth_status_for(
1423 provider: ProviderKind,
1424 base_url: &str,
1425 has_key: bool,
1426 configured: Option<&crate::config::ProviderConfig>,
1427 no_auth: bool,
1428 api_key_required: bool,
1429 official_endpoint: bool,
1430 xai_oauth_ready: bool,
1431 ) -> ProviderAuthStatus {
1432 if no_auth {
1433 return ProviderAuthStatus::NoAuth;
1434 }
1435 if crate::config::provider_route_is_keyless_self_hosted(provider, base_url) {
1436 if api_key_required {
1437 return if has_key {
1438 ProviderAuthStatus::Configured
1439 } else {
1440 ProviderAuthStatus::Missing
1441 };
1442 }
1443 if provider == ProviderKind::Ollama {
1444 return ProviderAuthStatus::Local;
1445 }
1446 return if has_explicit_credential(provider, configured) {
1447 ProviderAuthStatus::Configured
1448 } else {
1449 ProviderAuthStatus::Optional
1450 };
1451 }
1452 if provider == ProviderKind::Custom {
1453 return if custom_provider_auth_is_optional(configured) {
1454 ProviderAuthStatus::Optional
1455 } else if has_key {
1456 ProviderAuthStatus::Configured
1457 } else {
1458 ProviderAuthStatus::Missing
1459 };
1460 }
1461 if provider == ProviderKind::Moonshot
1462 && official_endpoint
1463 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
1464 {
1465 return ProviderAuthStatus::ImportedTokenUnavailable;
1466 }
1467 if provider == ProviderKind::OpenaiCodex && official_endpoint {
1468 return if has_key {
1469 ProviderAuthStatus::OAuthReady
1470 } else {
1471 ProviderAuthStatus::OAuthMissing
1472 };
1473 }
1474 if provider == ProviderKind::Xai
1475 && official_endpoint
1476 && let Some(status) = xai_oauth_status(configured, xai_oauth_ready)
1477 {
1478 return status;
1479 }
1480 if has_key {
1481 ProviderAuthStatus::Configured
1482 } else {
1483 ProviderAuthStatus::Missing
1484 }
1485 }
1486
1487 fn xai_oauth_status(
1488 configured: Option<&crate::config::ProviderConfig>,
1489 oauth_credentials_present: bool,
1490 ) -> Option<ProviderAuthStatus> {
1491 let oauth_selected = configured
1492 .and_then(|entry| entry.auth_mode.as_deref())
1493 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth);
1494 if !oauth_selected {
1495 return None;
1496 }
1497 Some(if oauth_credentials_present {
1498 ProviderAuthStatus::OAuthReady
1499 } else if has_explicit_credential(ProviderKind::Xai, configured) {
1500 ProviderAuthStatus::Configured
1501 } else {
1502 ProviderAuthStatus::OAuthMissing
1503 })
1504 }
1505
1506 fn has_explicit_credential(
1507 provider: ProviderKind,
1508 configured: Option<&crate::config::ProviderConfig>,
1509 ) -> bool {
1510 provider
1511 .provider()
1512 .env_vars()
1513 .iter()
1514 .any(|var| std::env::var(var).is_ok_and(|value| !value.trim().is_empty()))
1515 || configured.is_some_and(|entry| {
1516 entry.api_key.as_deref().is_some_and(|value| {
1517 crate::config::classify_config_api_key_value(value)
1518 == crate::config::ConfigApiKeyValueKind::Literal
1519 })
1520 })
1521 }
1522
1523 fn custom_provider_has_auth(configured: Option<&crate::config::ProviderConfig>) -> bool {
1524 if custom_provider_auth_is_optional(configured) {
1525 return true;
1526 }
1527 configured.is_some_and(|entry| {
1528 entry.api_key.as_deref().is_some_and(|value| {
1529 crate::config::classify_config_api_key_value(value)
1530 == crate::config::ConfigApiKeyValueKind::Literal
1531 }) || entry
1532 .api_key_env
1533 .as_deref()
1534 .map(str::trim)
1535 .filter(|name| !name.is_empty())
1536 .is_some_and(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()))
1537 })
1538 }
1539
1540 fn custom_provider_auth_is_optional(configured: Option<&crate::config::ProviderConfig>) -> bool {
1541 configured.is_some_and(|entry| {
1542 entry
1543 .auth_mode
1544 .as_deref()
1545 .is_some_and(|mode| crate::config::auth_mode_disables_api_key(Some(mode)))
1546 || entry
1547 .base_url
1548 .as_deref()
1549 .is_some_and(base_url_uses_local_host)
1550 })
1551 }
1552
1553 /// The actionable half of a failed credential resolution.
1554 ///
1555 /// "missing DEEPSEEK_API_KEY" told a user nothing about *where* the picker
1556 /// looked, which is why a key sitting in the secret store could read as
1557 /// "missing key" while the request path found it. Every message now carries
1558 /// the ordered places that were probed and the one command that fixes the
1559 /// first of them. Places and fixes are labels only — never secret material.
1560 fn missing_auth_message(
1561 provider: ProviderKind,
1562 configured: Option<&crate::config::ProviderConfig>,
1563 provider_id: &str,
1564 resolution: &crate::credentials::CredentialResolution,
1565 ) -> String {
1566 if provider == ProviderKind::Moonshot
1567 && configured.is_some_and(crate::config::provider_config_uses_kimi_imported_token)
1568 {
1569 return "Kimi OAuth is unavailable; configure a Kimi API key".to_string();
1570 }
1571 let headline = if provider == ProviderKind::Custom {
1572 match configured
1573 .and_then(|entry| entry.api_key_env.as_deref())
1574 .map(str::trim)
1575 .filter(|name| !name.is_empty())
1576 {
1577 Some(env_name) => format!("missing {env_name} for custom provider {provider_id}"),
1578 None => format!("missing custom provider auth for {provider_id}"),
1579 }
1580 } else {
1581 format!("missing {}", provider.provider().env_vars().join(" / "))
1582 };
1583 let mut message = headline;
1584 let checked = resolution.checked_places();
1585 if !checked.is_empty() {
1586 message.push_str(" · checked ");
1587 message.push_str(&checked);
1588 }
1589 if let Some(fix) = resolution.first_fix() {
1590 message.push_str(" · fix: ");
1591 message.push_str(fix);
1592 }
1593 message
1594 }
1595
1596 fn readiness_for(
1597 identity: &ProviderRouteIdentity,
1598 credential: CredentialState,
1599 route_ok: bool,
1600 health: &ProviderReadinessSnapshot,
1601 ) -> ResolvedProviderReadiness {
1602 crate::provider_readiness::resolve_with_identity(identity, credential, route_ok, health)
1603 }
1604
1605 /// Slice D: cost lives at the model level. Per-model $/mtok in/out projected
1606 /// from the merged catalog offering, with honest non-token fallbacks. `None`
1607 /// pricing is an unknown, never a fabricated zero.
1608 ///
1609 /// Provider-agnostic fallbacks keep the label truthful when the catalog has
1610 /// no row: self-hosted routes are local, Codex rides OAuth quota, and
1611 /// everything else is honestly unknown.
1612 fn configured_model_cost_label(config: &Config, row: &ProviderDashboardRow, model: &str) -> String {
1613 match row.billing_presentation {
1614 crate::route_billing::BillingPresentation::Subscription(label) => return label.to_string(),
1615 crate::route_billing::BillingPresentation::Local => return "local".to_string(),
1616 _ => {}
1617 }
1618 if let Some(declared) = crate::provider_lake::configured_model_for_route(
1619 config,
1620 row.provider,
1621 &row.provider_id,
1622 &row.base_url,
1623 model,
1624 ) {
1625 let card = codewhale_config::model_reference::ModelReferenceCard::from_offering(
1626 &declared.to_catalog_offering(),
1627 );
1628 return format!("{} · user estimate", card.price_label());
1629 }
1630 model_cost_label(row.provider, model)
1631 }
1632
1633 fn model_cost_label(provider: ProviderKind, model: &str) -> String {
1634 // OpenCode Go spends a subscription allowance, not per-token dollars, so
1635 // a catalog token price would misreport it as metered spend (#4526).
1636 if provider == ProviderKind::OpencodeGo {
1637 return "plan".to_string();
1638 }
1639 let pricing =
1640 catalog_offering_for_model(provider, model).map(|offering| offering.to_offering().pricing);
1641 let label = model_cost_label_for_pricing(provider, pricing.as_ref());
1642 if label != "price unknown" {
1643 return label;
1644 }
1645 // A catalog row can withhold a rate the reviewed table still owns:
1646 // DeepSeek bills by time of day (`pricing_withheld` in
1647 // catalog_corrections.json), and live roster rows carry no price at all.
1648 // Ask pricing.rs before calling the price unknown, as `/model` does.
1649 crate::pricing::model_rate_label(provider, model, crate::pricing::CostCurrency::Usd)
1650 .map_or(label, |rate| format!("{rate} per 1M"))
1651 }
1652
1653 /// Slice D two-pane picker: `(model, per-model cost, is_default_route)` rows
1654 /// for the models pane beside/under the provider strip. The default route's
1655 /// model sorts first so the eye lands on what Enter would use; the rest are
1656 /// alphabetical. ChatGPT keeps account ordering and requires an account roster;
1657 /// other providers can fall back to their default route.
1658 fn provider_pane_models(
1659 config: &Config,
1660 row: &ProviderDashboardRow,
1661 limit: usize,
1662 ) -> Vec<(String, String, bool)> {
1663 let mut models = crate::provider_lake::configured_catalog_models_for_route(
1664 config,
1665 row.provider,
1666 &row.provider_id,
1667 &row.base_url,
1668 );
1669 if row.provider != ProviderKind::OpenaiCodex
1670 && models.is_empty()
1671 && !row.default_route.logical_model.trim().is_empty()
1672 {
1673 models.push(row.default_route.logical_model.clone());
1674 }
1675 let default = row.default_route.logical_model.clone();
1676 let wire = row.default_route.wire_model.clone();
1677 if row.provider != ProviderKind::OpenaiCodex {
1678 models.sort_by_key(|model| model.to_ascii_lowercase());
1679 models.dedup_by_key(|model| model.to_ascii_lowercase());
1680 models.sort_by_key(|model| {
1681 (!model.eq_ignore_ascii_case(&default) && !model.eq_ignore_ascii_case(&wire)) as u8
1682 });
1683 }
1684 models
1685 .into_iter()
1686 .take(limit.max(1))
1687 .map(|model| {
1688 let is_default =
1689 model.eq_ignore_ascii_case(&default) || model.eq_ignore_ascii_case(&wire);
1690 let price = configured_model_cost_label(config, row, &model);
1691 (model, price, is_default)
1692 })
1693 .collect()
1694 }
1695
1696 fn model_cost_label_for_pricing(provider: ProviderKind, pricing: Option<&PricingSku>) -> String {
1697 // OpenCode Go spends a subscription allowance, not per-token dollars, so
1698 // a catalog token price would misreport it as metered spend (#4526).
1699 if provider == ProviderKind::OpencodeGo {
1700 return "plan".to_string();
1701 }
1702 match pricing {
1703 Some(PricingSku::Token {
1704 input_per_mtok,
1705 output_per_mtok,
1706 }) => match (input_per_mtok, output_per_mtok) {
1707 (Some(input), Some(output)) => format!("${input:.2}/${output:.2} per 1M"),
1708 _ => "token-priced".to_string(),
1709 },
1710 Some(PricingSku::SubscriptionQuota { .. }) => "plan".to_string(),
1711 Some(PricingSku::AccountCredits { .. }) => "credits".to_string(),
1712 Some(PricingSku::LocalOrNotApplicable) => "local".to_string(),
1713 Some(PricingSku::UnknownOrStale) | None => match provider {
1714 ProviderKind::Ollama | ProviderKind::Sglang | ProviderKind::Vllm => "local".to_string(),
1715 ProviderKind::OpenaiCodex => "oauth quota".to_string(),
1716 ProviderKind::OpencodeZen => "pay-as-you-go".to_string(),
1717 _ => "price unknown".to_string(),
1718 },
1719 }
1720 }
1721
1722 fn protocol_label(protocol: RequestProtocol) -> &'static str {
1723 match protocol {
1724 WireFormat::ChatCompletions => "chat",
1725 WireFormat::Responses => "responses",
1726 WireFormat::AnthropicMessages => "anthropic",
1727 }
1728 }
1729
1730 /// Whether a provider has a supported external credential owner at all.
1731 ///
1732 /// Pure provider metadata. Unlike [`external_consent_target_for_provider`] it
1733 /// resolves no path, so deciding whether to *offer* the reuse action never
1734 /// derives a HOME-based candidate location (#5772).
1735 #[must_use]
1736 pub(crate) fn provider_supports_external_consent(provider: ProviderKind) -> bool {
1737 matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai)
1738 }
1739
1740 /// Resolve the external credential target for a provider that supports
1741 /// read-only external consent. This is the same lower-level fact the
1742 /// provider picker uses to build its consent flow; Fleet setup reuses it
1743 /// for route-scoped activation without switching the parent session.
1744 ///
1745 /// #5772: only the disclosure and confirmation steps may call this. Ordinary
1746 /// browsing and key entry must use [`provider_supports_external_consent`],
1747 /// because the resolved candidate is exactly what the confirmation exists to
1748 /// disclose.
1749 #[must_use]
1750 pub(crate) fn external_consent_target_for_provider(
1751 provider: ProviderKind,
1752 ) -> Option<(
1753 codewhale_config::ProviderKind,
1754 codewhale_config::ExternalCredentialSource,
1755 std::path::PathBuf,
1756 )> {
1757 let (consent_provider, source, path) = match provider {
1758 ProviderKind::OpenaiCodex => (
1759 codewhale_config::ProviderKind::OpenaiCodex,
1760 codewhale_config::ExternalCredentialSource::CodexCli,
1761 crate::oauth::auth_file_path(),
1762 ),
1763 ProviderKind::Xai => (
1764 codewhale_config::ProviderKind::Xai,
1765 codewhale_config::ExternalCredentialSource::GrokCli,
1766 crate::oauth::grok_auth_file_path(),
1767 ),
1768 _ => return None,
1769 };
1770 let path = codewhale_config::resolve_external_credential_path(path).ok()?;
1771 Some((consent_provider, source, path))
1772 }
1773
1774 impl ProviderPickerView {
1775 /// OAuth-only providers never collect a typed credential: the key entry
1776 /// stage for them is a routing step (device flow / external consent), so
1777 /// typed input and pastes are accepted events but never stored.
1778 fn key_entry_is_oauth_locked(&self) -> bool {
1779 self.selected_provider()
1780 .provider()
1781 .credential_help()
1782 .acquisition
1783 == CredentialAcquisition::OAuth
1784 }
1785 /// Whether the person has pressed a key or clicked in this picker.
1786 pub(crate) fn interacted(&self) -> bool {
1787 self.interacted
1788 }
1789
1790 #[cfg(test)]
1791 #[must_use]
1792 pub fn new(active: ProviderKind, config: &Config) -> Self {
1793 Self::new_with_runtime_status(active, config, None)
1794 }
1795
1796 #[must_use]
1797 pub fn new_with_runtime_status(
1798 active: ProviderKind,
1799 config: &Config,
1800 runtime_status: Option<ProviderRuntimeStatus>,
1801 ) -> Self {
1802 Self::new_with_runtime_status_and_memory(active, config, runtime_status, None)
1803 }
1804
1805 #[must_use]
1806 pub fn new_with_runtime_status_and_memory(
1807 active: ProviderKind,
1808 config: &Config,
1809 runtime_status: Option<ProviderRuntimeStatus>,
1810 memory: Option<&crate::tui::app::ProviderPickerMemory>,
1811 ) -> Self {
1812 // Build the setup/catalog universe directly from ProviderKind::all so
1813 // first-run and recovery use the same canonical provider surface as
1814 // the runtime, not a historical onboarding shortlist. The active
1815 // provider is highlighted via `selected_idx` below, so it is never
1816 // lost in the list.
1817 let runtime_status = runtime_status.as_ref();
1818 let custom_rows = custom_provider_dashboard_rows(active, config, runtime_status);
1819 // Catalog surface = ProviderKind::ALL (one identity per vendor). Dual
1820 // dialect / plan-variant kinds stay resolvable but are not separate
1821 // rows; plan is mode/base_url and dialect is providers.<id>.wire.
1822 let catalog_active = active;
1823 let mut rows: Vec<ProviderDashboardRow> =
1824 codewhale_config::descriptors::provider_compatibility()
1825 .iter()
1826 .filter(|row| ProviderKind::ALL.contains(&row.kind) && row.id == row.kind.as_str())
1827 .map(|row| row.kind)
1828 .filter(|provider| *provider != ProviderKind::Custom || custom_rows.is_empty())
1829 .filter(|provider| {
1830 !custom_rows
1831 .iter()
1832 .any(|row| row.provider_id == provider.as_str())
1833 })
1834 .map(|p| {
1835 ProviderDashboardRow::from_config_with_runtime_status(
1836 p,
1837 catalog_active,
1838 config,
1839 runtime_status,
1840 )
1841 })
1842 .collect();
1843 rows.extend(custom_rows);
1844 let active_identity = config.active_provider_identity().ok();
1845 for row in codewhale_config::descriptors::provider_compatibility() {
1846 if row.kind == ProviderKind::Custom
1847 || rows.iter().any(|existing| existing.provider_id == row.id)
1848 {
1849 continue;
1850 }
1851 let active = active_identity
1852 .as_ref()
1853 .is_some_and(|identity| identity.key.as_str() == row.id);
1854 let configured = config.providers.as_ref().is_some_and(|providers| {
1855 codewhale_config::provider_config_table!(@read providers, row.id).is_some()
1856 });
1857 if active || configured {
1858 rows.push(ProviderDashboardRow::from_config_with_provider_id(
1859 row.kind,
1860 catalog_active,
1861 config,
1862 Some(row.id),
1863 config.provider.as_deref(),
1864 runtime_status,
1865 ));
1866 }
1867 }
1868 rows.extend(descriptor_dashboard_rows(active, config, runtime_status));
1869 // Providers you have configured lead; the rest of the catalog follows
1870 // alphabetically. Founder live-test: "we should also make that list
1871 // ordered logically so like the ones you have configured at the top
1872 // then everything else below". This orders the Catalog view too, where
1873 // the whole forty-provider list is shown at once — the Configured view
1874 // is already filtered to the same set that now leads here.
1875 rows.sort_by(|a, b| {
1876 b.is_configured
1877 .cmp(&a.is_configured)
1878 .then_with(|| {
1879 a.display_name
1880 .to_ascii_lowercase()
1881 .cmp(&b.display_name.to_ascii_lowercase())
1882 })
1883 .then_with(|| a.provider_id.cmp(&b.provider_id))
1884 });
1885 let selected_idx = rows
1886 .iter()
1887 .position(|row| row.is_active)
1888 .or_else(|| rows.iter().position(|row| row.provider == active))
1889 .unwrap_or(0);
1890 // Default to the configured-only view (#3830); if nothing is
1891 // configured yet (a fresh install), open straight on the full
1892 // catalog instead of an empty list with no obvious next step.
1893 let view = if rows.iter().any(|row| row.is_configured) {
1894 ProviderListView::Configured
1895 } else {
1896 ProviderListView::Catalog
1897 };
1898 let mut picker = Self {
1899 route_config: config.clone(),
1900 rows,
1901 selected_idx,
1902 stage: Stage::List,
1903 view,
1904 setup_mode: false,
1905 onboarding_mode: false,
1906 query: String::new(),
1907 search_mode: false,
1908 api_key_input: String::new(),
1909 key_entry_error: None,
1910 locale: Locale::En,
1911 xai_auth_choice: XaiAuthChoice::ApiKey,
1912 external_consent_choice: ExternalConsentChoice::Disabled,
1913 external_revoke_return: Stage::List,
1914 interacted: false,
1915 pending_api_key: None,
1916 model_options: Vec::new(),
1917 model_selected_idx: 0,
1918 selected_model: None,
1919 selected_context_window: None,
1920 kimi_code_plan_tier: KimiCodePlanTier::Safe262k,
1921 stepfun_billing_route: StepfunBillingRoute::PayAsYouGo,
1922 pending_base_url: None,
1923 custom_provider_field: CustomProviderField::Name,
1924 custom_provider_id: String::new(),
1925 custom_provider_base_url: String::new(),
1926 custom_provider_model: String::new(),
1927 custom_provider_api_key_env: String::new(),
1928 custom_provider_descriptor: None,
1929 list_row_hitboxes: RefCell::new(Vec::new()),
1930 model_row_hitboxes: RefCell::new(Vec::new()),
1931 consent_row_hitboxes: RefCell::new(Vec::new()),
1932 choice_row_hitboxes: RefCell::new(Vec::new()),
1933 detail_action_hitbox: RefCell::new(None),
1934 catalog_action_hitbox: RefCell::new(None),
1935 catalog_action_hovered: false,
1936 detail_action_hovered: false,
1937 hovered_choice: None,
1938 last_choice_mouse_selected: None,
1939 hovered_list_idx: None,
1940 hovered_model_idx: None,
1941 hovered_consent_idx: None,
1942 last_list_mouse_selected: None,
1943 last_model_mouse_selected: None,
1944 };
1945 picker.restore_memory(memory);
1946 picker
1947 }
1948
1949 #[must_use]
1950 pub(crate) fn with_locale(mut self, locale: Locale) -> Self {
1951 self.locale = locale;
1952 self
1953 }
1954
1955 fn tr(&self, id: MessageId) -> Cow<'static, str> {
1956 tr(self.locale, id)
1957 }
1958
1959 /// Apply session-local request evidence after the static catalog rows are
1960 /// built. Saved credentials stay "not checked" until this snapshot proves
1961 /// success; a failed check remains visible and retryable with its reason.
1962 #[must_use]
1963 pub(crate) fn with_provider_health(mut self, health: &ProviderReadinessSnapshot) -> Self {
1964 for row in &mut self.rows {
1965 let Some(identity) = row.route_identity.as_ref() else {
1966 continue;
1967 };
1968 row.readiness = readiness_for(identity, row.credential_state, row.route_ok, health);
1969 if let Some(detail) = row.readiness.detail()
1970 && !row.messages.iter().any(|message| message == detail)
1971 {
1972 row.messages.push(detail.to_string());
1973 }
1974 }
1975 self
1976 }
1977
1978 /// Restore browsing context from the last dismissed `/provider` picker.
1979 fn restore_memory(&mut self, memory: Option<&crate::tui::app::ProviderPickerMemory>) {
1980 let Some(memory) = memory else {
1981 return;
1982 };
1983 if memory.catalog_view {
1984 self.view = ProviderListView::Catalog;
1985 }
1986 if let Some(remembered_id) = memory.selected_provider_id.as_deref()
1987 && let Some(idx) = self
1988 .rows
1989 .iter()
1990 .position(|row| row.provider_id == remembered_id)
1991 && (self.row_visible(idx) || memory.catalog_view)
1992 {
1993 if memory.catalog_view {
1994 self.view = ProviderListView::Catalog;
1995 }
1996 self.selected_idx = idx;
1997 }
1998 if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
1999 self.selected_idx = (0..self.rows.len())
2000 .find(|idx| self.row_visible(*idx))
2001 .unwrap_or(0);
2002 }
2003 }
2004
2005 /// Open the picker as a first-run/setup catalog: every built-in provider is
2006 /// visible, and an optional target is focused. Missing-auth targets jump
2007 /// straight to the existing masked key-entry stage; configured/local
2008 /// targets stay on the list so Enter applies them normally.
2009 #[must_use]
2010 pub fn new_for_setup(
2011 active: ProviderKind,
2012 target: Option<codewhale_config::ProviderId>,
2013 config: &Config,
2014 runtime_status: Option<ProviderRuntimeStatus>,
2015 ) -> Self {
2016 Self::new_for_setup_inner(active, target, config, runtime_status, true)
2017 }
2018
2019 /// Open the named OpenAI-compatible form with DS4's keyless local
2020 /// defaults filled in. DS4 uses the existing transport, not a new adapter.
2021 #[must_use]
2022 pub fn new_for_ds4_setup(
2023 active: ProviderKind,
2024 config: &Config,
2025 runtime_status: Option<ProviderRuntimeStatus>,
2026 ) -> Self {
2027 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
2028 picker.setup_mode = true;
2029 picker.enter_ds4_form();
2030 picker
2031 }
2032
2033 /// Open the setup catalog for first-run/recovery onboarding (#4763).
2034 /// Identical to [`Self::new_for_setup`] except that a missing-auth
2035 /// `target` is only *focused*: onboarding must show the navigable
2036 /// provider list before it asks for a secret, so key/OAuth entry is
2037 /// reached by picking a row, never by opening straight into it.
2038 #[must_use]
2039 pub fn new_for_onboarding(
2040 active: ProviderKind,
2041 target: Option<codewhale_config::ProviderId>,
2042 config: &Config,
2043 runtime_status: Option<ProviderRuntimeStatus>,
2044 ) -> Self {
2045 let mut picker = Self::new_for_setup_inner(active, target, config, runtime_status, false);
2046 picker.onboarding_mode = true;
2047 picker
2048 }
2049
2050 fn new_for_setup_inner(
2051 active: ProviderKind,
2052 target: Option<codewhale_config::ProviderId>,
2053 config: &Config,
2054 runtime_status: Option<ProviderRuntimeStatus>,
2055 key_entry_for_missing_auth: bool,
2056 ) -> Self {
2057 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
2058 // First-run setup shows the full provider catalog (hosted + local)
2059 // so a user can pick DeepSeek or any other API immediately. Local-only
2060 // is opt-in via "explore offline" and the L toggle. A local-first
2061 // default (introduced 2026-08-15) hid hosted providers behind a
2062 // keypress, which read as "only local models are supported."
2063 picker.view = ProviderListView::Catalog;
2064 picker.setup_mode = true;
2065 if let Some(target) = target.and_then(|key| {
2066 config
2067 .resolve_provider_selection_identity(key.as_str())
2068 .ok()
2069 }) && let Some(idx) = picker
2070 .rows
2071 .iter()
2072 .position(|row| row.identity.as_ref() == Some(&target))
2073 {
2074 picker.selected_idx = idx;
2075 // A provider that already has a key is *focused*, not re-prompted:
2076 // `R` is the rekey affordance and the footer advertises it. Only a
2077 // provider missing auth drops straight onto its key prompt.
2078 if key_entry_for_missing_auth && !picker.selected_has_key() {
2079 picker.begin_setup();
2080 }
2081 }
2082 picker
2083 }
2084
2085 /// Open the picker already focused on `target` in its key-entry stage —
2086 /// the missing-auth handoff (#3830): when a route switch is rejected for
2087 /// want of a key, drop the user straight onto that provider's key prompt
2088 /// instead of dead-ending with an error. Falls back to the normal list
2089 /// if the target has no row (e.g. an unknown custom id).
2090 #[must_use]
2091 /// Returns `None` when `target` has no picker row (an unknown/custom
2092 /// provider we could not focus or key-enter) so the caller can keep its
2093 /// honest error instead of opening a dead-end picker.
2094 pub fn new_for_missing_auth(
2095 active: ProviderKind,
2096 target: &crate::config::ProviderIdentity,
2097 config: &Config,
2098 runtime_status: Option<ProviderRuntimeStatus>,
2099 ) -> Option<Self> {
2100 config.verify_provider_identity(target).ok()?;
2101 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
2102 let idx = picker
2103 .rows
2104 .iter()
2105 .position(|row| row.identity.as_ref() == Some(target))?;
2106 picker.selected_idx = idx;
2107 // The target may be an unconfigured catalog row; show the catalog so
2108 // it is visible, then jump into key entry for it.
2109 picker.view = ProviderListView::Catalog;
2110 picker.begin_setup();
2111 Some(picker)
2112 }
2113
2114 fn row_visible(&self, idx: usize) -> bool {
2115 let query = self.query.trim();
2116 if !query.is_empty() {
2117 return self.rows[idx].matches_query(query);
2118 }
2119 match self.view {
2120 ProviderListView::Catalog => true,
2121 ProviderListView::Configured => self.rows[idx].is_configured,
2122 ProviderListView::Local => {
2123 self.rows[idx]
2124 .provider
2125 .provider()
2126 .credential_help()
2127 .acquisition
2128 == CredentialAcquisition::LocalOptional
2129 }
2130 }
2131 }
2132
2133 fn visible_row_count(&self) -> usize {
2134 (0..self.rows.len())
2135 .filter(|idx| self.row_visible(*idx))
2136 .count()
2137 }
2138
2139 /// Toggle between the configured-only and full-catalog views (#3830),
2140 /// keeping the current selection if it stays visible and otherwise
2141 /// jumping to the first visible row (`rows` is sorted alphabetically by
2142 /// display name, so this lands on the alphabetically-first match, not
2143 /// necessarily the row positionally nearest the old selection).
2144 fn toggle_view(&mut self) {
2145 self.view = match self.view {
2146 ProviderListView::Configured => ProviderListView::Catalog,
2147 ProviderListView::Catalog => ProviderListView::Configured,
2148 ProviderListView::Local => ProviderListView::Catalog,
2149 };
2150 if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
2151 self.selected_idx = (0..self.rows.len())
2152 .find(|idx| self.row_visible(*idx))
2153 .unwrap_or(0);
2154 }
2155 }
2156
2157 /// Show only the built-in keyless/self-hosted routes. Kept separate from
2158 /// `Configured`: merely supporting a local route does not mean the user
2159 /// configured it, while first-run should still make those routes obvious.
2160 fn show_local_routes(&mut self) {
2161 self.view = ProviderListView::Local;
2162 self.query.clear();
2163 if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
2164 self.selected_idx = self
2165 .rows
2166 .iter()
2167 .position(|row| row.provider == ProviderKind::Ollama)
2168 .or_else(|| (0..self.rows.len()).find(|idx| self.row_visible(*idx)))
2169 .unwrap_or(0);
2170 }
2171 }
2172
2173 /// Update the search query and clamp the selection to the first visible row.
2174 fn update_query(&mut self, next: String) {
2175 self.query = next;
2176 self.selected_idx = (0..self.rows.len())
2177 .find(|idx| self.row_visible(*idx))
2178 .unwrap_or(0);
2179 }
2180
2181 /// Move the selection one visible row forward (`step = 1`) or backward
2182 /// (`step = -1`), skipping rows hidden by the current `view` filter
2183 /// (#3830) and wrapping at the ends.
2184 fn move_selection(&mut self, step: i64) {
2185 let count = self.rows.len();
2186 if count == 0 || self.visible_row_count() == 0 {
2187 return;
2188 }
2189 let mut idx = self.selected_idx;
2190 loop {
2191 idx = ((idx as i64 + step).rem_euclid(count as i64)) as usize;
2192 if self.row_visible(idx) {
2193 self.selected_idx = idx;
2194 return;
2195 }
2196 }
2197 }
2198
2199 fn move_up(&mut self) {
2200 self.move_selection(-1);
2201 }
2202
2203 /// Apply one [`list_nav`] motion to the provider list (#6290).
2204 ///
2205 /// The key vocabulary is single-sourced in `list_nav`; this surface owns
2206 /// only what a motion means here: step motions wrap through the visible
2207 /// rows (existing behavior), page and edge motions clamp and never land
2208 /// on a row the active filter hides.
2209 fn move_by_list_motion(&mut self, key: &KeyEvent) {
2210 let Some(motion) = list_nav::motion_while_typing(key) else {
2211 return;
2212 };
2213 match motion {
2214 Motion::Prev => self.move_selection(-1),
2215 Motion::Next => self.move_selection(1),
2216 Motion::PagePrev => self.move_selection_clamped(-(PROVIDER_PAGE as i64)),
2217 Motion::PageNext => self.move_selection_clamped(PROVIDER_PAGE as i64),
2218 Motion::First => self.select_first_visible(),
2219 Motion::Last => self.select_last_visible(),
2220 // Single-column surface: the region axis has nowhere to move to.
2221 Motion::RegionPrev | Motion::RegionNext => {}
2222 }
2223 }
2224
2225 /// Clamped sibling of [`Self::move_selection`]: no wrap (a paging key asks
2226 /// to travel, not to teleport — `list_nav`'s contract), and rows the
2227 /// filter hides are never landed on.
2228 fn move_selection_clamped(&mut self, step: i64) {
2229 let count = self.rows.len();
2230 if count == 0 || self.visible_row_count() == 0 {
2231 return;
2232 }
2233 let last = count - 1;
2234 let target = (self.selected_idx as i64 + step).clamp(0, last as i64) as usize;
2235 let found = if step >= 0 {
2236 (target..=last).find(|&index| self.row_visible(index))
2237 } else {
2238 (0..=target).rev().find(|&index| self.row_visible(index))
2239 };
2240 if let Some(index) = found {
2241 self.selected_idx = index;
2242 }
2243 }
2244
2245 fn select_first_visible(&mut self) {
2246 if let Some(index) = (0..self.rows.len()).find(|&index| self.row_visible(index)) {
2247 self.selected_idx = index;
2248 }
2249 }
2250
2251 fn select_last_visible(&mut self) {
2252 if let Some(index) = (0..self.rows.len())
2253 .rev()
2254 .find(|&index| self.row_visible(index))
2255 {
2256 self.selected_idx = index;
2257 }
2258 }
2259
2260 fn move_down(&mut self) {
2261 self.move_selection(1);
2262 }
2263
2264 fn selected_provider(&self) -> ProviderKind {
2265 self.rows[self.selected_idx].provider
2266 }
2267
2268 #[cfg(test)]
2269 fn selected_provider_id(&self) -> Option<String> {
2270 self.rows[self.selected_idx]
2271 .identity
2272 .as_ref()
2273 .and_then(|identity| identity.persisted_id().map(str::to_string))
2274 }
2275
2276 fn selected_identity(&self) -> Option<ProviderIdentity> {
2277 let identity = self.rows[self.selected_idx].identity.as_ref()?;
2278 self.route_config.verify_provider_identity(identity).ok()?;
2279 Some(identity.clone())
2280 }
2281
2282 fn selected_has_key(&self) -> bool {
2283 if self.selected_identity().is_none() {
2284 return false;
2285 }
2286 if self.selected_provider() == ProviderKind::OpenaiCodex {
2287 return crate::oauth::credentials_valid(
2288 crate::oauth::OAuthProvider::Chatgpt,
2289 &self.route_config,
2290 );
2291 }
2292 matches!(
2293 self.rows[self.selected_idx].credential_state,
2294 CredentialState::Saved
2295 | CredentialState::ExternalConsent
2296 | CredentialState::ImportedToken
2297 | CredentialState::NoAuth
2298 | CredentialState::Local
2299 | CredentialState::Legacy
2300 )
2301 }
2302
2303 fn selected_route_is_valid(&self) -> bool {
2304 self.rows[self.selected_idx].route_ok
2305 }
2306
2307 /// The provider refused this row's credential in this session. Enter
2308 /// then asks for a new key instead of reusing the refused one, which
2309 /// only loops back into the same rejection.
2310 fn selected_credential_rejected(&self) -> bool {
2311 matches!(
2312 self.rows[self.selected_idx].readiness,
2313 ResolvedProviderReadiness::SavedLastCheckFailed {
2314 category: crate::error_taxonomy::ErrorCategory::Authentication,
2315 ..
2316 }
2317 )
2318 }
2319
2320 fn enter_key_entry(&mut self) {
2321 self.stage = Stage::KeyEntry;
2322 self.api_key_input.clear();
2323 self.key_entry_error = None;
2324 self.pending_api_key = None;
2325 self.pending_base_url = None;
2326 self.model_options.clear();
2327 self.model_selected_idx = 0;
2328 self.selected_model = None;
2329 }
2330
2331 /// Start guided setup for the selected row. Providers that bill on more
2332 /// than one endpoint choose the route first so the key is validated
2333 /// against the endpoint it will actually be saved for (#4526).
2334 fn begin_setup(&mut self) {
2335 if self.selected_provider() == ProviderKind::Xai {
2336 self.enter_xai_auth_choice();
2337 } else if self.selected_provider() == ProviderKind::OpenaiCodex {
2338 self.enter_chatgpt_auth_choice();
2339 } else if self.stepfun_billing_route_applies() {
2340 self.enter_stepfun_billing_route();
2341 } else {
2342 self.enter_key_entry();
2343 }
2344 }
2345
2346 fn enter_chatgpt_auth_choice(&mut self) {
2347 self.stage = Stage::ChatgptAuthChoice;
2348 self.api_key_input.clear();
2349 self.key_entry_error = None;
2350 self.pending_api_key = None;
2351 }
2352
2353 fn enter_xai_auth_choice(&mut self) {
2354 self.xai_auth_choice = XaiAuthChoice::ApiKey;
2355 self.stage = Stage::XaiAuthChoice;
2356 self.api_key_input.clear();
2357 self.key_entry_error = None;
2358 self.pending_api_key = None;
2359 }
2360
2361 fn move_xai_auth_choice(&mut self) {
2362 self.xai_auth_choice = match self.xai_auth_choice {
2363 XaiAuthChoice::ApiKey => XaiAuthChoice::DeviceOAuth,
2364 XaiAuthChoice::DeviceOAuth => XaiAuthChoice::ApiKey,
2365 };
2366 }
2367
2368 fn stepfun_billing_route_applies(&self) -> bool {
2369 self.rows
2370 .get(self.selected_idx)
2371 .is_some_and(|row| stepfun_route_is_selectable(row.provider, &row.base_url))
2372 }
2373
2374 fn enter_stepfun_billing_route(&mut self) {
2375 // Preselect whatever the row already resolves to so re-running setup
2376 // on a configured Step Plan route does not default back to PAYG.
2377 self.stepfun_billing_route = if crate::pricing::billing_surface_for_route(
2378 ProviderKind::Stepfun,
2379 Some(&self.rows[self.selected_idx].base_url),
2380 ) == Some(crate::pricing::STEPFUN_PLAN_BILLING_SURFACE)
2381 {
2382 StepfunBillingRoute::StepPlan
2383 } else {
2384 StepfunBillingRoute::PayAsYouGo
2385 };
2386 self.stage = Stage::StepfunBillingRoute;
2387 }
2388
2389 fn apply_stepfun_billing_route(&mut self) {
2390 let base_url = self.stepfun_billing_route.base_url().to_string();
2391 self.enter_key_entry();
2392 self.rows[self.selected_idx].base_url.clone_from(&base_url);
2393 self.pending_base_url = Some(base_url);
2394 }
2395
2396 fn selected_external_consent_target(
2397 &self,
2398 ) -> Option<(
2399 codewhale_config::ProviderKind,
2400 codewhale_config::ExternalCredentialSource,
2401 std::path::PathBuf,
2402 )> {
2403 external_consent_target_for_provider(self.selected_provider())
2404 }
2405
2406 fn enter_external_consent_choice(&mut self) {
2407 // Capability only (#5772): the candidate path stays unresolved until
2408 // the user picks ReadOnly and the confirmation discloses it.
2409 if provider_supports_external_consent(self.selected_provider()) {
2410 self.external_consent_choice = ExternalConsentChoice::Disabled;
2411 self.stage = Stage::ExternalConsentChoice;
2412 }
2413 }
2414
2415 fn move_external_consent_choice(&mut self, delta: isize) {
2416 let index = match self.external_consent_choice {
2417 ExternalConsentChoice::Disabled => 0,
2418 ExternalConsentChoice::ReadOnly => 1,
2419 ExternalConsentChoice::ManagedUnavailable => 2,
2420 };
2421 self.external_consent_choice = match (index as isize + delta).rem_euclid(3) {
2422 0 => ExternalConsentChoice::Disabled,
2423 1 => ExternalConsentChoice::ReadOnly,
2424 _ => ExternalConsentChoice::ManagedUnavailable,
2425 };
2426 }
2427
2428 fn build_external_consent_event(&self) -> Option<ViewEvent> {
2429 let (provider, source, path) = self.selected_external_consent_target()?;
2430 Some(ViewEvent::ProviderPickerExternalConsentConfirmed {
2431 provider: self.selected_provider(),
2432 consent_provider: provider,
2433 source,
2434 path,
2435 })
2436 }
2437
2438 /// Open the picker already focused on `target` in its key-entry stage
2439 /// with a validation error message - the verify-then-persist handoff
2440 /// (#3875): when a submitted key fails live validation, drop the user
2441 /// back on that provider's key prompt with the provider's actual error
2442 /// instead of dead-ending with a status toast.
2443 #[must_use]
2444 pub fn new_for_key_entry_with_error(
2445 active: ProviderKind,
2446 target: &crate::config::ProviderIdentity,
2447 config: &Config,
2448 runtime_status: Option<ProviderRuntimeStatus>,
2449 error: String,
2450 ) -> Option<Self> {
2451 config.verify_provider_identity(target).ok()?;
2452 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
2453 let idx = picker
2454 .rows
2455 .iter()
2456 .position(|row| row.identity.as_ref() == Some(target))?;
2457 picker.selected_idx = idx;
2458 picker.view = ProviderListView::Catalog;
2459 picker.stage = Stage::KeyEntry;
2460 picker.key_entry_error = Some(error);
2461 Some(picker)
2462 }
2463
2464 /// Open the guided flow on the model-pick stage after a key has been
2465 /// live-validated (#3875). The key stays in memory only until confirm.
2466 #[must_use]
2467 pub fn new_for_model_pick_after_validation(
2468 active: ProviderKind,
2469 target: &crate::config::ProviderIdentity,
2470 config: &Config,
2471 runtime_status: Option<ProviderRuntimeStatus>,
2472 api_key: String,
2473 base_url: Option<String>,
2474 ) -> Option<Self> {
2475 config.verify_provider_identity(target).ok()?;
2476 let mut picker = Self::new_with_runtime_status(active, config, runtime_status);
2477 let idx = picker
2478 .rows
2479 .iter()
2480 .position(|row| row.identity.as_ref() == Some(target))?;
2481 picker.selected_idx = idx;
2482 picker.view = ProviderListView::Catalog;
2483 picker.pending_api_key = Some(api_key);
2484 // The wizard's endpoint choice survives the validation round-trip so
2485 // confirm persists exactly the route the key was verified against.
2486 if let Some(base_url) = base_url {
2487 picker.rows[idx].base_url.clone_from(&base_url);
2488 picker.pending_base_url = Some(base_url);
2489 }
2490 picker.api_key_input.clear();
2491 picker.key_entry_error = None;
2492 picker.enter_model_pick();
2493 Some(picker)
2494 }
2495
2496 fn enter_model_pick(&mut self) {
2497 self.stage = Stage::ModelPick;
2498 self.selected_context_window = None;
2499 let provider = self.selected_provider();
2500 let route = &self.rows[self.selected_idx].default_route;
2501 let kimi_code_k3 = crate::config::is_exact_kimi_code_k3_route(
2502 provider,
2503 &self.rows[self.selected_idx].base_url,
2504 &route.wire_model,
2505 );
2506 // Recovery must restore the configured wire route, not replace bare
2507 // K3 with whichever generic Moonshot catalog entry happens to sort
2508 // first. Keep this route-local; `k3` is intentionally not added to
2509 // the global Moonshot catalog.
2510 let preferred = if kimi_code_k3 {
2511 route.wire_model.clone()
2512 } else {
2513 route.logical_model.clone()
2514 };
2515 let row = &self.rows[self.selected_idx];
2516 let mut models = crate::provider_lake::configured_catalog_models_for_route(
2517 &self.route_config,
2518 provider,
2519 &row.provider_id,
2520 &row.base_url,
2521 );
2522 if kimi_code_k3
2523 && !preferred.trim().is_empty()
2524 && !models
2525 .iter()
2526 .any(|model| model.eq_ignore_ascii_case(preferred.trim()))
2527 {
2528 models.push(preferred.clone());
2529 }
2530 if provider != ProviderKind::OpenaiCodex
2531 && models.is_empty()
2532 && !preferred.trim().is_empty()
2533 {
2534 models.push(preferred.clone());
2535 }
2536 if provider != ProviderKind::OpenaiCodex && models.is_empty() {
2537 // Last-resort so the guided flow never dead-ends without a choice.
2538 models.push(provider.as_str().to_string());
2539 }
2540 // Without a live roster the list is catalog rows, which can still name
2541 // a compatibility alias this route rewrites to another id (DeepSeek's
2542 // retired `deepseek-chat` -> `deepseek-v4-flash`). Offering both shows
2543 // one model twice under a name the provider no longer serves; keep
2544 // the id the route actually sends whenever that id is also listed.
2545 let base_url = self.rows[self.selected_idx].base_url.clone();
2546 let wire =
2547 |model: &str| crate::config::wire_model_for_provider_route(provider, &base_url, model);
2548 let listed: Vec<String> = models.clone();
2549 models.retain(|model| {
2550 let target = wire(model);
2551 target == model.trim() || !listed.contains(&target)
2552 });
2553 let preferred_wire = wire(&preferred);
2554 let selected = models
2555 .iter()
2556 .position(|model| model.eq_ignore_ascii_case(preferred.trim()))
2557 .or_else(|| {
2558 models
2559 .iter()
2560 .position(|model| model.eq_ignore_ascii_case(preferred_wire.trim()))
2561 })
2562 .unwrap_or(0);
2563 self.model_options = models;
2564 self.model_selected_idx = selected.min(self.model_options.len().saturating_sub(1));
2565 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
2566 }
2567
2568 fn enter_confirm(&mut self) {
2569 if self.selected_model.is_none() {
2570 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
2571 }
2572 self.stage = Stage::Confirm;
2573 }
2574
2575 fn selected_kimi_code_k3(&self) -> bool {
2576 let Some(model) = self.selected_model.as_deref() else {
2577 return false;
2578 };
2579 crate::config::is_exact_kimi_code_bare_k3_route(
2580 self.selected_provider(),
2581 &self.rows[self.selected_idx].base_url,
2582 model,
2583 )
2584 }
2585
2586 fn enter_plan_tier(&mut self) {
2587 self.stage = Stage::PlanTier;
2588 self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
2589 }
2590
2591 fn apply_plan_tier(&mut self) {
2592 self.selected_context_window = Some(match self.kimi_code_plan_tier {
2593 KimiCodePlanTier::Safe262k => codewhale_models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS,
2594 KimiCodePlanTier::OneMillion => 1_048_576,
2595 });
2596 self.enter_confirm();
2597 }
2598
2599 fn move_model_selection(&mut self, delta: isize) {
2600 let len = self.model_options.len();
2601 if len == 0 {
2602 return;
2603 }
2604 let current = self.model_selected_idx as isize;
2605 let next = (current + delta).rem_euclid(len as isize) as usize;
2606 self.model_selected_idx = next;
2607 self.selected_model = self.model_options.get(next).cloned();
2608 }
2609
2610 fn build_setup_confirmed_event(&self) -> Option<ViewEvent> {
2611 let api_key = self.pending_api_key.as_ref()?.trim();
2612 if api_key.is_empty() {
2613 return None;
2614 }
2615 let model = self
2616 .selected_model
2617 .as_ref()
2618 .map(|value| value.trim())
2619 .filter(|value| !value.is_empty())?;
2620 Some(ViewEvent::ProviderPickerSetupConfirmed {
2621 identity: self.selected_identity()?,
2622 api_key: api_key.to_string(),
2623 model: model.to_string(),
2624 context_window: self.selected_context_window,
2625 base_url: self.pending_base_url.clone(),
2626 })
2627 }
2628
2629 /// Open the custom-provider form with whatever a known host already
2630 /// pins, leaving the cursor on the first field the user still has to
2631 /// decide. Every entry point into `Stage::CustomForm` goes through here.
2632 fn prefill_custom_form(
2633 &mut self,
2634 provider_id: &str,
2635 base_url: &str,
2636 model: &str,
2637 api_key_env: &str,
2638 field: CustomProviderField,
2639 ) {
2640 self.stage = Stage::CustomForm;
2641 self.custom_provider_field = field;
2642 self.custom_provider_id = provider_id.to_string();
2643 self.custom_provider_base_url = base_url.to_string();
2644 self.custom_provider_model = model.to_string();
2645 self.custom_provider_api_key_env = api_key_env.to_string();
2646 self.custom_provider_descriptor = None;
2647 }
2648
2649 fn enter_custom_form(&mut self) {
2650 self.prefill_custom_form("", "", "", "", CustomProviderField::Name);
2651 }
2652
2653 /// A bundled descriptor row (#6289) is set up as the named custom
2654 /// provider it describes: the JSON pins id, endpoint, bootstrap model and
2655 /// credential env var, so the only field left is which env var holds the
2656 /// key. Submitting writes `[providers.<id>]` through the same path a
2657 /// hand-entered custom provider uses.
2658 fn enter_descriptor_form(&mut self, descriptor: &'static ProviderDescriptor) {
2659 self.prefill_custom_form(
2660 &descriptor.id,
2661 &descriptor.base_url,
2662 &descriptor.default_model,
2663 &descriptor.api_key_env,
2664 CustomProviderField::ApiKeyEnv,
2665 );
2666 self.custom_provider_descriptor = Some(descriptor);
2667 }
2668
2669 fn enter_ds4_form(&mut self) {
2670 self.prefill_custom_form(
2671 DS4_PROVIDER_ID,
2672 DS4_BASE_URL,
2673 DS4_DEFAULT_MODEL,
2674 "",
2675 CustomProviderField::ApiKeyEnv,
2676 );
2677 }
2678
2679 fn enter_lm_studio_form(&mut self) {
2680 // LM Studio model identifiers depend on what the user has loaded, so
2681 // leave the model editable instead of guessing a stale default.
2682 self.prefill_custom_form(
2683 LM_STUDIO_PROVIDER_ID,
2684 LM_STUDIO_BASE_URL,
2685 "",
2686 "",
2687 CustomProviderField::Model,
2688 );
2689 }
2690
2691 fn custom_form_field_mut(&mut self) -> &mut String {
2692 match self.custom_provider_field {
2693 CustomProviderField::Name => &mut self.custom_provider_id,
2694 CustomProviderField::BaseUrl => &mut self.custom_provider_base_url,
2695 CustomProviderField::Model => &mut self.custom_provider_model,
2696 CustomProviderField::ApiKeyEnv => &mut self.custom_provider_api_key_env,
2697 }
2698 }
2699
2700 fn custom_form_field_value(&self, field: CustomProviderField) -> &str {
2701 match field {
2702 CustomProviderField::Name => &self.custom_provider_id,
2703 CustomProviderField::BaseUrl => &self.custom_provider_base_url,
2704 CustomProviderField::Model => &self.custom_provider_model,
2705 CustomProviderField::ApiKeyEnv => &self.custom_provider_api_key_env,
2706 }
2707 }
2708
2709 fn advance_custom_field(&mut self) {
2710 self.custom_provider_field = match self.custom_provider_field {
2711 CustomProviderField::Name => CustomProviderField::BaseUrl,
2712 CustomProviderField::BaseUrl => CustomProviderField::Model,
2713 CustomProviderField::Model => CustomProviderField::ApiKeyEnv,
2714 CustomProviderField::ApiKeyEnv => CustomProviderField::ApiKeyEnv,
2715 };
2716 }
2717
2718 fn retreat_custom_field(&mut self) {
2719 self.custom_provider_field = match self.custom_provider_field {
2720 CustomProviderField::Name => CustomProviderField::Name,
2721 CustomProviderField::BaseUrl => CustomProviderField::Name,
2722 CustomProviderField::Model => CustomProviderField::BaseUrl,
2723 CustomProviderField::ApiKeyEnv => CustomProviderField::Model,
2724 };
2725 }
2726
2727 fn build_custom_provider_event(&self) -> Option<ViewEvent> {
2728 let provider_id = self.custom_provider_id.trim();
2729 let base_url = self.custom_provider_base_url.trim();
2730 if provider_id.is_empty() || base_url.is_empty() {
2731 return None;
2732 }
2733 let model = non_empty_string(&self.custom_provider_model);
2734 let api_key_env = non_empty_string(&self.custom_provider_api_key_env);
2735 Some(ViewEvent::ProviderPickerCustomProviderSubmitted {
2736 provider_id: provider_id.to_string(),
2737 base_url: base_url.to_string(),
2738 model,
2739 api_key_env,
2740 })
2741 }
2742
2743 fn env_var_for(provider: ProviderKind) -> String {
2744 provider.provider().env_vars().join(" / ")
2745 }
2746
2747 fn env_var_for_selected_row(&self) -> String {
2748 let row = &self.rows[self.selected_idx];
2749 if row.provider == ProviderKind::Custom {
2750 return row
2751 .messages
2752 .iter()
2753 .find_map(|message| {
2754 message
2755 .strip_prefix("missing ")
2756 .and_then(|rest| rest.split_once(" for custom provider"))
2757 .map(|(env_name, _)| env_name.to_string())
2758 })
2759 .unwrap_or_else(|| format!("[providers.{}] api_key", row.provider_id));
2760 }
2761 Self::env_var_for(row.provider)
2762 }
2763
2764 /// Rows visible under the current `view` filter (#3830), as
2765 /// `(original_index, row)` pairs so callers can still compare against
2766 /// `self.selected_idx`.
2767 fn filtered_rows(&self) -> Vec<(usize, &ProviderDashboardRow)> {
2768 self.rows
2769 .iter()
2770 .enumerate()
2771 .filter(|(idx, _)| self.row_visible(*idx))
2772 .collect()
2773 }
2774
2775 fn visible_start(selected_pos: usize, total: usize, visible_rows: usize) -> usize {
2776 if visible_rows == 0 {
2777 return 0;
2778 }
2779 let max_start = total.saturating_sub(visible_rows);
2780 selected_pos
2781 .saturating_add(1)
2782 .saturating_sub(visible_rows)
2783 .min(max_start)
2784 }
2785
2786 fn render_list(&self, area: Rect, buf: &mut Buffer) {
2787 let enter_action = if self.rows[self.selected_idx].is_custom_placeholder() {
2788 self.tr(MessageId::PickerActionCustom)
2789 } else if !self.selected_route_is_valid() {
2790 self.tr(MessageId::PickerActionUnavailable)
2791 } else if self.selected_has_key() && !self.selected_credential_rejected() {
2792 self.tr(MessageId::PickerActionApply)
2793 } else {
2794 self.tr(MessageId::PickerActionSetKey)
2795 };
2796 let title = if !self.onboarding_mode && (self.search_mode || !self.query.is_empty()) {
2797 format!(
2798 "{}: {}",
2799 self.tr(MessageId::SessionsActionSearch),
2800 self.query
2801 )
2802 } else if self.onboarding_mode {
2803 format!(" {} ", self.tr(MessageId::OnboardProviderTitle))
2804 } else {
2805 match (self.setup_mode, self.view) {
2806 (true, ProviderListView::Configured) => {
2807 format!(" Provider setup{} ", catalog_freshness_title_suffix())
2808 }
2809 (true, ProviderListView::Catalog) => {
2810 format!(" Provider setup · all{} ", catalog_freshness_title_suffix())
2811 }
2812 (true, ProviderListView::Local) => " Local models · no cloud key ".to_string(),
2813 (false, ProviderListView::Configured) => {
2814 format!(" Provider{} ", catalog_freshness_title_suffix())
2815 }
2816 (false, ProviderListView::Catalog) => {
2817 format!(" Provider · all{} ", catalog_freshness_title_suffix())
2818 }
2819 (false, ProviderListView::Local) => " Provider · local only ".to_string(),
2820 }
2821 };
2822 let view_action = match self.view {
2823 ProviderListView::Configured => self.tr(MessageId::PickerActionBrowseAll),
2824 ProviderListView::Catalog => self.tr(MessageId::PickerActionConfigured),
2825 ProviderListView::Local => self.tr(MessageId::PickerActionBrowseAll),
2826 };
2827 let action_label = crate::tui::ui_text::semantic_truncate(
2828 &view_action,
2829 usize::from(area.width.saturating_sub(20)),
2830 );
2831 let action_width = unicode_width::UnicodeWidthStr::width(action_label.as_str()) as u16;
2832 let show_action = !self.onboarding_mode && area.width >= 28 && area.height > 0;
2833 let title = if show_action {
2834 crate::tui::ui_text::semantic_truncate(
2835 title.trim(),
2836 usize::from(area.width.saturating_sub(action_width + 8)),
2837 )
2838 } else {
2839 title
2840 };
2841 let inner = if self.onboarding_mode {
2842 let outer = Block::default()
2843 .title(Line::from(Span::styled(
2844 title,
2845 Style::default()
2846 .fg(palette::WHALE_ACTION)
2847 .add_modifier(Modifier::BOLD),
2848 )))
2849 .borders(Borders::ALL)
2850 .border_style(Style::default().fg(palette::BORDER_COLOR))
2851 .style(Style::default().bg(palette::WHALE_BG));
2852 let inner = outer.inner(area);
2853 outer.render(area, buf);
2854 inner
2855 } else {
2856 render_underwater_surface(area, buf, title.trim())
2857 };
2858
2859 if show_action {
2860 let action = Rect::new(
2861 inner.right().saturating_sub(action_width),
2862 area.y + u16::from(area.height >= 24),
2863 action_width,
2864 1,
2865 );
2866 *self.catalog_action_hitbox.borrow_mut() = Some(action);
2867 Paragraph::new(action_label)
2868 .style(if self.catalog_action_hovered {
2869 menu_style::hovered_row_style()
2870 } else {
2871 Style::default()
2872 .fg(palette::WHALE_ACTION)
2873 .add_modifier(Modifier::UNDERLINED)
2874 })
2875 .render(action, buf);
2876 }
2877 let search_active = self.search_mode || !self.query.trim().is_empty();
2878 // The action footer moves into the body so it wraps instead of clipping
2879 // at narrow widths (#3732); the provider list renders above it.
2880 let content = if self.onboarding_mode {
2881 let mut hints = vec![
2882 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2883 ActionHint::new("Enter", enter_action),
2884 ];
2885 if self.view == ProviderListView::Local {
2886 hints.push(ActionHint::new(
2887 "A",
2888 self.tr(MessageId::PickerActionBrowseAll),
2889 ));
2890 }
2891 hints.extend([
2892 ActionHint::new("Ctrl+O", self.tr(MessageId::OnboardProviderOffline)),
2893 ActionHint::new("Esc", self.tr(MessageId::OnboardActionBack)),
2894 ]);
2895 render_modal_footer(inner, buf, &hints)
2896 } else if search_active {
2897 render_modal_footer(
2898 inner,
2899 buf,
2900 &[
2901 // Two-stage Esc (clear the query, then cancel) reads as one
2902 // hint instead of a duplicated key.
2903 ActionHint::new(
2904 "Esc",
2905 format!(
2906 "{} / {}",
2907 self.tr(MessageId::PickerActionClear),
2908 self.tr(MessageId::PickerActionCancel)
2909 ),
2910 ),
2911 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2912 ActionHint::new("Enter", enter_action),
2913 ],
2914 )
2915 } else if inner.height < 16 {
2916 // Keep the selection and recovery actions visible before teaching
2917 // secondary shortcuts; the full rail returns with vertical room.
2918 render_modal_footer(
2919 inner,
2920 buf,
2921 &[
2922 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2923 ActionHint::new("Enter", enter_action),
2924 ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)),
2925 ActionHint::new("M", self.tr(MessageId::PickerActionModels)),
2926 ActionHint::new("/", self.tr(MessageId::SessionsActionSearch)),
2927 ActionHint::new("Esc", self.tr(MessageId::PickerActionCancel)),
2928 ],
2929 )
2930 } else {
2931 render_modal_footer(
2932 inner,
2933 buf,
2934 &[
2935 ActionHint::new("↑↓", self.tr(MessageId::PickerActionMove)),
2936 ActionHint::new("/", self.tr(MessageId::SessionsActionSearch)),
2937 ActionHint::new("Enter", enter_action),
2938 ActionHint::new("A", view_action),
2939 // The footer advertises actions for the selected row and
2940 // the list as a whole. `L` local-only, `I` LM Studio,
2941 // `C` custom, `D` DS4 and `S` SenseNova were setup forms
2942 // for five specific providers out of forty, given
2943 // top-level keys — founder live-test: "please remove D S I
2944 // etc". The keys still work for anyone who learned them;
2945 // they are simply no longer taught here, because the way
2946 // to reach a provider is to select its row.
2947 ActionHint::new("R", self.tr(MessageId::PickerActionEditKey)),
2948 ActionHint::new("M", self.tr(MessageId::PickerActionModels)),
2949 ActionHint::new("C-t", self.tr(MessageId::PickerActionTestConnection)),
2950 ActionHint::new(
2951 "E",
2952 self.tr(if self.selected_provider() == ProviderKind::OpenaiCodex {
2953 MessageId::ChatgptAuthChoicePkceOption
2954 } else {
2955 MessageId::ProviderExternalActionChoices
2956 }),
2957 ),
2958 ActionHint::new("X", self.tr(MessageId::ProviderExternalActionRevoke)),
2959 ActionHint::new("Esc", self.tr(MessageId::PickerActionCancel)),
2960 ],
2961 )
2962 };
2963
2964 let filtered = self.filtered_rows();
2965 if filtered.is_empty() {
2966 if search_active {
2967 EmptyState::new(
2968 self.tr(MessageId::ProviderNoMatchesTitle),
2969 self.tr(MessageId::ProviderNoMatchesHint),
2970 )
2971 .primary_action("Esc", self.tr(MessageId::PickerActionClearSearch))
2972 .render(content, buf);
2973 } else {
2974 EmptyState::new(
2975 self.tr(MessageId::ProviderNoConfiguredTitle),
2976 self.tr(MessageId::ProviderNoConfiguredHint),
2977 )
2978 .primary_action("A", self.tr(MessageId::PickerActionBrowseAll))
2979 .secondary_action("C", self.tr(MessageId::PickerActionCustom))
2980 .render(content, buf);
2981 }
2982 return;
2983 }
2984
2985 // Onboarding asks one question. The ordinary provider manager keeps
2986 // its technical detail pane, but first-run gives the available rows
2987 // the whole body so 40x12 still has room to choose and proceed.
2988 let mut layout = if self.onboarding_mode {
2989 ListDetailLayout {
2990 list: content,
2991 detail: Rect::new(content.x, content.y, 0, 0),
2992 stacked: false,
2993 }
2994 } else {
2995 ListDetailLayout::split(content, 34)
2996 };
2997 if layout.stacked && filtered.len() < usize::from(layout.list.height) {
2998 layout.list.height = filtered.len() as u16;
2999 let detail_y = layout.list.bottom().saturating_add(1).min(content.bottom());
3000 layout.detail.y = detail_y;
3001 layout.detail.height = content.bottom().saturating_sub(detail_y);
3002 }
3003 let selected_pos = filtered
3004 .iter()
3005 .position(|(idx, _)| *idx == self.selected_idx)
3006 .unwrap_or(0);
3007 let visible_rows = usize::from(layout.list.height);
3008 let visible_start = Self::visible_start(selected_pos, filtered.len(), visible_rows);
3009 // Slice D two-pane picker: the provider strip lives on the left and
3010 // every visible row is clickable, so record this frame's geometry for
3011 // hover + click handling (mirrors the model picker hitboxes).
3012 self.list_row_hitboxes.borrow_mut().clear();
3013 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
3014 for (pos, (idx, row)) in filtered
3015 .iter()
3016 .enumerate()
3017 .skip(visible_start)
3018 .take(visible_rows)
3019 {
3020 let is_selected = *idx == self.selected_idx;
3021 debug_assert_eq!(is_selected, pos == selected_pos);
3022 let is_active = row.is_active;
3023 let arrow = crate::tui::glyphs::selection_marker(is_selected);
3024 let active_dot = if is_active { " *" } else { " " };
3025 let spacer_style = if is_selected {
3026 menu_style::selected_row_bg_style()
3027 } else {
3028 Style::default()
3029 };
3030 let is_hovered = self.hovered_list_idx == Some(*idx);
3031 let label_style = if is_selected {
3032 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
3033 } else if is_hovered {
3034 menu_style::hovered_row_style()
3035 } else {
3036 Style::default().fg(palette::TEXT_PRIMARY)
3037 };
3038 let has_usable_auth = matches!(
3039 row.credential_state,
3040 CredentialState::Saved
3041 | CredentialState::ImportedToken
3042 | CredentialState::NoAuth
3043 | CredentialState::Local
3044 | CredentialState::Legacy
3045 );
3046 let hint_style = if is_selected {
3047 menu_style::selected_row_style_with_fg(if has_usable_auth {
3048 palette::SELECTION_TEXT
3049 } else {
3050 palette::STATUS_WARNING
3051 })
3052 } else if has_usable_auth {
3053 Style::default().fg(palette::TEXT_MUTED)
3054 } else {
3055 Style::default().fg(palette::STATUS_WARNING)
3056 };
3057 let prefix = format!(" {arrow} {}{active_dot} ", row.display_name);
3058 let hint = crate::tui::ui_text::semantic_truncate_between_affixes(
3059 &prefix,
3060 &row.list_row_hint(self.view),
3061 "",
3062 usize::from(layout.list.width),
3063 );
3064 let mut line = Line::from(vec![
3065 Span::styled(" ", spacer_style),
3066 Span::styled(arrow, label_style),
3067 Span::styled(" ", spacer_style),
3068 Span::styled(row.display_name.as_str(), label_style),
3069 Span::styled(active_dot, label_style),
3070 Span::styled(" ", spacer_style),
3071 Span::styled(hint, hint_style),
3072 ]);
3073 if is_hovered && !is_selected {
3074 line.style = menu_style::hovered_row_style();
3075 }
3076 if is_selected {
3077 line.style = menu_style::selected_row_bg_style();
3078 let target_width = usize::from(layout.list.width);
3079 let line_width = line.width();
3080 if line_width < target_width {
3081 line.spans.push(Span::styled(
3082 " ".repeat(target_width - line_width),
3083 menu_style::selected_row_bg_style(),
3084 ));
3085 }
3086 }
3087 let row_y = layout.list.y.saturating_add(lines.len() as u16);
3088 if is_hovered && !is_selected {
3089 buf.set_style(
3090 Rect::new(layout.list.x, row_y, layout.list.width, 1),
3091 menu_style::hovered_row_style(),
3092 );
3093 }
3094 self.list_row_hitboxes
3095 .borrow_mut()
3096 .push((Rect::new(layout.list.x, row_y, layout.list.width, 1), *idx));
3097 lines.push(line);
3098 }
3099 Paragraph::new(lines).render(layout.list, buf);
3100 if !self.onboarding_mode {
3101 self.render_provider_detail(layout.detail, buf, &self.rows[self.selected_idx]);
3102 }
3103 }
3104
3105 fn render_provider_detail(&self, area: Rect, buf: &mut Buffer, row: &ProviderDashboardRow) {
3106 *self.detail_action_hitbox.borrow_mut() = None;
3107 if area.width == 0 || area.height == 0 {
3108 return;
3109 }
3110 // A quiet inspector shares the canvas with the list. The explicit
3111 // details action opens the existing pager for complete diagnostics.
3112 let action_label = format!(
3113 "{} {}",
3114 crate::tui::shell_key_routing::tool_details_chord(),
3115 self.tr(MessageId::CtxMenuOpenDetails)
3116 );
3117 let action_width =
3118 (unicode_width::UnicodeWidthStr::width(action_label.as_str()) as u16).min(area.width);
3119 let action = Rect::new(
3120 area.right().saturating_sub(action_width),
3121 area.y,
3122 action_width,
3123 1,
3124 );
3125 *self.detail_action_hitbox.borrow_mut() = Some(action);
3126 let action_style = if self.detail_action_hovered {
3127 menu_style::hovered_row_style().fg(palette::WHALE_ACTION)
3128 } else {
3129 Style::default()
3130 .fg(palette::WHALE_ACTION)
3131 .add_modifier(Modifier::UNDERLINED)
3132 };
3133 Paragraph::new(action_label)
3134 .style(action_style)
3135 .render(action, buf);
3136 let title = Rect::new(
3137 area.x,
3138 area.y,
3139 area.width.saturating_sub(action_width + 1),
3140 1,
3141 );
3142 Paragraph::new(crate::tui::ui_text::semantic_truncate(
3143 &row.display_name,
3144 usize::from(title.width),
3145 ))
3146 .style(Style::default().fg(palette::TEXT_PRIMARY).bold())
3147 .render(title, buf);
3148 let inner = Rect::new(
3149 area.x,
3150 area.y.saturating_add(2),
3151 area.width,
3152 area.height.saturating_sub(2),
3153 );
3154 Paragraph::new(self.provider_detail_lines(row, inner.width, false))
3155 .wrap(Wrap { trim: true })
3156 .render(inner, buf);
3157 }
3158
3159 fn open_provider_details(&self) -> ViewAction {
3160 if !self.row_visible(self.selected_idx) {
3161 return ViewAction::None;
3162 }
3163 let row = &self.rows[self.selected_idx];
3164 let content = self
3165 .provider_detail_lines(row, u16::MAX, true)
3166 .iter()
3167 .map(|line| {
3168 line.spans
3169 .iter()
3170 .map(|span| span.content.as_ref())
3171 .collect::<String>()
3172 })
3173 .collect::<Vec<_>>()
3174 .join("\n");
3175 ViewAction::Emit(ViewEvent::OpenTextPager {
3176 title: format!(
3177 "{} · {}",
3178 row.display_name,
3179 self.tr(MessageId::CtxMenuOpenDetails)
3180 ),
3181 content,
3182 })
3183 }
3184
3185 fn provider_detail_lines(
3186 &self,
3187 row: &ProviderDashboardRow,
3188 width: u16,
3189 full: bool,
3190 ) -> Vec<Line<'static>> {
3191 let route = if row.default_route.logical_model == row.default_route.wire_model {
3192 row.default_route.logical_model.clone()
3193 } else {
3194 format!(
3195 "{} -> {}",
3196 row.default_route.logical_model, row.default_route.wire_model
3197 )
3198 };
3199 let mut lines = vec![
3200 Line::from(Span::styled(
3201 // The maturity tag used to ride the list row's pipe dump. The
3202 // row is short now, so the fact lives here, with the rest of
3203 // the provider's detail.
3204 row.detail_state_line(),
3205 Style::default().fg(if row.credential_state == CredentialState::MissingKey {
3206 palette::STATUS_WARNING
3207 } else {
3208 palette::TEXT_MUTED
3209 }),
3210 )),
3211 Line::from(Span::styled(
3212 // Whether this model is the provider's default, one the
3213 // operator saved, or a custom id. It used to ride the list
3214 // row's pipe dump; the row is short now and this is where the
3215 // route's own facts live.
3216 // "Route" is internal vocabulary (§19); the person picks a
3217 // model (#6566).
3218 format!("Model: {route} · {}", row.model_origin.label()),
3219 Style::default().fg(palette::TEXT_PRIMARY),
3220 )),
3221 Line::from(Span::styled(
3222 format!("Endpoint: {}", row.base_url),
3223 Style::default().fg(palette::TEXT_MUTED),
3224 )),
3225 ];
3226 // Keep a resolved credential's origin visible. An absent credential
3227 // is already named by readiness; its search details remain in the pager.
3228 if full || row.credential_source != "not found" {
3229 lines.insert(
3230 1,
3231 Line::from(Span::styled(
3232 format!("Credential: {}", row.credential_source),
3233 Style::default().fg(palette::TEXT_MUTED),
3234 )),
3235 );
3236 }
3237 // Protocol/capability details explain a route, but must not crowd out
3238 // its model choices and prices. Credential warnings and consent stay
3239 // ahead of the model inventory; technical diagnostics follow it.
3240 let diagnostics = vec![
3241 Line::from(Span::styled(
3242 format!("Protocol: {}", row.supported_protocols.join("+")),
3243 Style::default().fg(palette::TEXT_MUTED),
3244 )),
3245 Line::from(Span::styled(
3246 format!("Capabilities: {}", row.capabilities.label()),
3247 Style::default().fg(palette::TEXT_MUTED),
3248 )),
3249 Line::from(Span::styled(
3250 format!("Reasoning: {}", row.reasoning.label()),
3251 Style::default().fg(palette::TEXT_MUTED),
3252 )),
3253 ];
3254 if let Some(concurrency) = row.request_concurrency.label() {
3255 lines.push(Line::from(Span::styled(
3256 concurrency,
3257 Style::default().fg(palette::TEXT_MUTED),
3258 )));
3259 }
3260 for message in row.messages.iter().take(if full { usize::MAX } else { 2 }) {
3261 let message = if full {
3262 message.clone()
3263 } else {
3264 crate::tui::ui_text::semantic_truncate(
3265 message,
3266 usize::from(width.saturating_sub(2)),
3267 )
3268 };
3269 lines.push(Line::from(vec![
3270 Span::styled("! ", Style::default().fg(palette::STATUS_WARNING)),
3271 Span::styled(message, Style::default().fg(palette::TEXT_PRIMARY)),
3272 ]));
3273 }
3274 // #5772: the external block exists only for a persisted consent record
3275 // (see the row constructor), and it names the owning CLI without the
3276 // file path. The exact path is disclosed solely inside the explicit
3277 // reuse confirmation; a dormant candidate must never leak HOME-derived
3278 // locations into ordinary browsing.
3279 if let Some(status) = row.external_credential_status.as_ref() {
3280 let state = if status.route_state == "active" {
3281 self.tr(MessageId::CtxInspActive)
3282 } else {
3283 self.tr(MessageId::ProviderExternalDormant)
3284 };
3285 let scope = self
3286 .tr(MessageId::ProviderExternalDetailScope)
3287 .replace("{access}", status.access.as_str())
3288 .replace("{provider}", &status.provider)
3289 .replace("{source}", status.source.as_str())
3290 .replace("{version}", &status.consent_version.to_string())
3291 .replace("{state}", &state);
3292 lines.push(Line::from(Span::styled(
3293 scope,
3294 Style::default().fg(palette::TEXT_MUTED),
3295 )));
3296 let owner = self
3297 .tr(MessageId::ProviderExternalOwnerOnly)
3298 .replace("{owner}", status.owner);
3299 let mut owner_spans = vec![Span::styled(
3300 owner,
3301 Style::default().fg(palette::TEXT_MUTED),
3302 )];
3303 if status.ambient_path_changed {
3304 let warning = self
3305 .tr(MessageId::ProviderExternalPinnedPathChanged)
3306 .replace("{owner}", status.owner);
3307 owner_spans.push(Span::styled(
3308 " | ",
3309 Style::default().fg(palette::TEXT_MUTED),
3310 ));
3311 owner_spans.push(Span::styled(
3312 warning,
3313 Style::default().fg(palette::STATUS_WARNING),
3314 ));
3315 }
3316 lines.push(Line::from(owner_spans));
3317 let semantics = match status.access {
3318 codewhale_config::ExternalCredentialAccess::Disabled => {
3319 self.tr(MessageId::ProviderExternalDisabledDetail)
3320 }
3321 codewhale_config::ExternalCredentialAccess::ReadOnly => {
3322 self.tr(MessageId::ProviderExternalReadOnlySemantics)
3323 }
3324 codewhale_config::ExternalCredentialAccess::Managed => {
3325 self.tr(MessageId::ProviderExternalManagedDetail)
3326 }
3327 };
3328 lines.push(Line::from(Span::styled(
3329 semantics,
3330 Style::default().fg(palette::TEXT_MUTED),
3331 )));
3332 let revoke = self
3333 .tr(MessageId::ProviderExternalRevoke)
3334 .replace("{revoke}", &status.revoke_command);
3335 lines.push(Line::from(Span::styled(
3336 revoke,
3337 Style::default().fg(palette::TEXT_MUTED),
3338 )));
3339 }
3340 // Slice D two-pane picker: the selected provider's models live
3341 // beside (wide) or under (narrow) the provider strip, each with its
3342 // own $/mtok in/out from the catalog. Credential and consent facts
3343 // lead; model choices come before low-level route diagnostics.
3344 // Display-only — choosing a model happens in the model picker (`M`)
3345 // or the guided setup flow.
3346 lines.push(Line::from(""));
3347 lines.push(Line::from(Span::styled(
3348 "Models · price in/out",
3349 Style::default()
3350 .fg(palette::TEXT_PRIMARY)
3351 .add_modifier(Modifier::BOLD),
3352 )));
3353 let pane_models = provider_pane_models(&self.route_config, row, 8);
3354 let name_budget = usize::from(width).saturating_sub(22).max(8);
3355 for (model, price, is_default) in &pane_models {
3356 let name = crate::tui::ui_text::truncate_line_to_width(model, name_budget);
3357 let mut spans = vec![
3358 Span::styled(" ", Style::default()),
3359 Span::styled(name, Style::default().fg(palette::TEXT_PRIMARY)),
3360 Span::styled(
3361 format!(" {price}"),
3362 Style::default().fg(palette::TEXT_MUTED),
3363 ),
3364 ];
3365 if *is_default {
3366 spans.push(Span::styled(
3367 " (default)",
3368 Style::default().fg(palette::WHALE_ACTION),
3369 ));
3370 }
3371 lines.push(Line::from(spans));
3372 }
3373 let total_models = crate::provider_lake::configured_catalog_models_for_route(
3374 &self.route_config,
3375 row.provider,
3376 &row.provider_id,
3377 &row.base_url,
3378 )
3379 .len();
3380 if total_models > pane_models.len() {
3381 lines.push(Line::from(Span::styled(
3382 format!(" +{} more · M for all", total_models - pane_models.len()),
3383 Style::default().fg(palette::TEXT_MUTED),
3384 )));
3385 }
3386 if full {
3387 lines.push(Line::from(""));
3388 lines.extend(diagnostics);
3389 }
3390 lines
3391 }
3392
3393 fn render_xai_auth_choice(&self, area: Rect, buf: &mut Buffer) {
3394 let outer = Block::default()
3395 .title(Line::from(Span::styled(
3396 self.tr(MessageId::XaiAuthChoiceTitle),
3397 Style::default()
3398 .fg(palette::WHALE_ACTION)
3399 .add_modifier(Modifier::BOLD),
3400 )))
3401 .borders(Borders::ALL)
3402 .border_style(Style::default().fg(palette::BORDER_COLOR))
3403 .style(Style::default().bg(palette::WHALE_BG));
3404 let inner = outer.inner(area);
3405 outer.render(area, buf);
3406 let content = render_modal_footer(
3407 inner,
3408 buf,
3409 &[
3410 ActionHint::new("↑↓/1-2", self.tr(MessageId::ProviderExternalActionChoose)),
3411 ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
3412 ActionHint::new("E", self.tr(MessageId::ProviderExternalActionReuseGrok)),
3413 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
3414 ],
3415 );
3416 self.render_setup_choices(
3417 content,
3418 buf,
3419 vec![Line::from(self.tr(MessageId::XaiAuthChoiceIntro))],
3420 [
3421 self.tr(MessageId::XaiAuthChoiceApiKeyOption).into_owned(),
3422 self.tr(MessageId::XaiAuthChoiceDeviceOAuthOption)
3423 .into_owned(),
3424 ],
3425 usize::from(self.xai_auth_choice == XaiAuthChoice::DeviceOAuth),
3426 );
3427 }
3428
3429 fn render_chatgpt_auth_choice(&self, area: Rect, buf: &mut Buffer) {
3430 let outer = Block::default()
3431 .title(Line::from(Span::styled(
3432 self.tr(MessageId::ChatgptAuthChoiceTitle),
3433 Style::default()
3434 .fg(palette::WHALE_ACTION)
3435 .add_modifier(Modifier::BOLD),
3436 )))
3437 .borders(Borders::ALL)
3438 .border_style(Style::default().fg(palette::BORDER_COLOR))
3439 .style(Style::default().bg(palette::WHALE_BG));
3440 let inner = outer.inner(area);
3441 outer.render(area, buf);
3442 let content = render_modal_footer(
3443 inner,
3444 buf,
3445 &[
3446 ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
3447 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
3448 ],
3449 );
3450 self.render_setup_choices(
3451 content,
3452 buf,
3453 vec![Line::from(self.tr(MessageId::ChatgptAuthChoiceIntro))],
3454 [self.tr(MessageId::ChatgptAuthChoicePkceOption).into_owned()],
3455 0,
3456 );
3457 }
3458
3459 fn render_key_entry(&self, area: Rect, buf: &mut Buffer) {
3460 let row = &self.rows[self.selected_idx];
3461 let codex_oauth = row.provider == ProviderKind::OpenaiCodex;
3462 let oauth_provider = codex_oauth;
3463 let saved_credential = !oauth_provider && row.has_key;
3464 let outer = Block::default()
3465 .title(Line::from(Span::styled(
3466 if oauth_provider {
3467 format!(" OAuth login — {} ", row.display_name)
3468 } else {
3469 format!(" API key — {} ", row.display_name)
3470 },
3471 Style::default()
3472 .fg(palette::WHALE_ACTION)
3473 .add_modifier(Modifier::BOLD),
3474 )))
3475 .borders(Borders::ALL)
3476 .border_style(Style::default().fg(palette::BORDER_COLOR))
3477 .style(Style::default().bg(palette::WHALE_BG));
3478 let inner = outer.inner(area);
3479 outer.render(area, buf);
3480
3481 // The action footer moves into the body so it wraps instead of clipping
3482 // at narrow widths (#3732); the key-entry fields render above it.
3483 let content = if codex_oauth {
3484 render_modal_footer(
3485 inner,
3486 buf,
3487 &[
3488 ActionHint::new("Enter", self.tr(MessageId::ChatgptAuthChoicePkceOption)),
3489 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
3490 ],
3491 )
3492 } else if saved_credential && self.api_key_input.trim().is_empty() {
3493 render_modal_footer(
3494 inner,
3495 buf,
3496 &[
3497 ActionHint::new("Type/paste", "replace the key"),
3498 ActionHint::new("Esc", "keep current key"),
3499 ],
3500 )
3501 } else {
3502 render_modal_footer(
3503 inner,
3504 buf,
3505 &[
3506 ActionHint::new("Enter", "continue"),
3507 ActionHint::new("Esc", "back"),
3508 ],
3509 )
3510 };
3511
3512 let masked = mask_key(&self.api_key_input);
3513 let display = if codex_oauth {
3514 self.tr(MessageId::ChatgptAuthChoicePkceOption).into_owned()
3515 } else if masked.is_empty() && saved_credential {
3516 // The key may come from the environment rather than a save, so
3517 // "saved" was not always true (#6566).
3518 "A key is already set up".to_string()
3519 } else if masked.is_empty() {
3520 "(paste key here)".to_string()
3521 } else {
3522 masked
3523 };
3524 let key_lines = vec![Line::from(vec![
3525 Span::styled(
3526 if oauth_provider { "Auth: " } else { "Key: " },
3527 Style::default().fg(palette::TEXT_MUTED),
3528 ),
3529 Span::styled(
3530 display,
3531 Style::default()
3532 .fg(palette::TEXT_PRIMARY)
3533 .add_modifier(Modifier::BOLD),
3534 ),
3535 ])];
3536 let reopen_command = if self.setup_mode {
3537 "/setup provider"
3538 } else {
3539 "/provider"
3540 };
3541 let mut hint_lines = if codex_oauth {
3542 vec![Line::from(Span::styled(
3543 self.tr(MessageId::ChatgptAuthChoiceIntro),
3544 Style::default().fg(palette::TEXT_MUTED),
3545 ))]
3546 } else if saved_credential && self.api_key_input.trim().is_empty() {
3547 vec![Line::from(Span::styled(
3548 "This terminal can use the stored credential. Type or paste only to replace it; Esc keeps it unchanged.",
3549 Style::default().fg(palette::TEXT_MUTED),
3550 ))]
3551 } else if saved_credential {
3552 vec![Line::from(Span::styled(
3553 "The replacement is validated before it replaces the stored credential.",
3554 Style::default().fg(palette::TEXT_MUTED),
3555 ))]
3556 } else {
3557 vec![Line::from(Span::styled(
3558 format!(
3559 "Or set the {} environment variable and re-open {reopen_command}.",
3560 self.env_var_for_selected_row(),
3561 ),
3562 Style::default().fg(palette::TEXT_MUTED),
3563 ))]
3564 };
3565 if !oauth_provider {
3566 if row.provider == ProviderKind::Moonshot
3567 && crate::config::moonshot_base_url_is_exact_kimi_code(&row.base_url)
3568 {
3569 hint_lines.extend([
3570 Line::from(Span::styled(
3571 self.tr(MessageId::KimiCodePlanApiKeyHint).replace(
3572 "{console}",
3573 crate::config::KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL,
3574 ),
3575 Style::default().fg(palette::TEXT_MUTED),
3576 )),
3577 Line::from(Span::styled(
3578 self.tr(MessageId::KimiCodePlanRouteHint)
3579 .replace("{route}", crate::config::DEFAULT_KIMI_CODE_BASE_URL),
3580 Style::default().fg(palette::TEXT_MUTED),
3581 )),
3582 Line::from(Span::styled(
3583 self.tr(MessageId::KimiCodePlanNoImportHint),
3584 Style::default().fg(palette::TEXT_MUTED),
3585 )),
3586 ]);
3587 } else {
3588 let help = row.provider.provider().credential_help();
3589 hint_lines.push(Line::from(Span::styled(
3590 help.credential_url.map_or_else(
3591 || format!("Credentials: {}", help.guidance),
3592 |url| format!("Credentials: {url}"),
3593 ),
3594 Style::default().fg(palette::TEXT_MUTED),
3595 )));
3596 if let Some(url) = help.docs_url {
3597 hint_lines.push(Line::from(Span::styled(
3598 format!("Docs: {url}"),
3599 Style::default().fg(palette::TEXT_MUTED),
3600 )));
3601 }
3602 }
3603 };
3604
3605 if let Some(ref error) = self.key_entry_error {
3606 hint_lines.push(Line::from(Span::styled(
3607 error.clone(),
3608 Style::default().fg(palette::STATUS_ERROR),
3609 )));
3610 }
3611
3612 // `Line` count is not rendered row count: long environment-variable
3613 // guidance can wrap to two or three terminal rows. Ask ratatui for the
3614 // exact wrapped height instead of duplicating its layout arithmetic.
3615 let hint = Paragraph::new(hint_lines).wrap(Wrap { trim: true });
3616 let hint_height = u16::try_from(hint.line_count(content.width.max(1)))
3617 .unwrap_or(u16::MAX)
3618 .clamp(1, 6);
3619 let layout = Layout::default()
3620 .direction(Direction::Vertical)
3621 .constraints([
3622 Constraint::Length(3),
3623 Constraint::Length(hint_height),
3624 Constraint::Min(1),
3625 ])
3626 .split(content);
3627
3628 Paragraph::new(key_lines).render(layout[0], buf);
3629 hint.render(layout[1], buf);
3630 }
3631
3632 fn render_external_consent_choice(&self, area: Rect, buf: &mut Buffer) {
3633 let provider_name = self.rows[self.selected_idx].display_name.clone();
3634 let outer = Block::default()
3635 .title(Line::from(Span::styled(
3636 self.tr(MessageId::ProviderExternalChoiceTitle)
3637 .replace("{provider}", &provider_name),
3638 Style::default()
3639 .fg(palette::WHALE_ACTION)
3640 .add_modifier(Modifier::BOLD),
3641 )))
3642 .borders(Borders::ALL)
3643 .border_style(Style::default().fg(palette::BORDER_COLOR))
3644 .style(Style::default().bg(palette::WHALE_BG));
3645 let inner = outer.inner(area);
3646 outer.render(area, buf);
3647 let content = render_modal_footer(
3648 inner,
3649 buf,
3650 &[
3651 ActionHint::new("↑↓", self.tr(MessageId::ProviderExternalActionChoose)),
3652 ActionHint::new("Enter", self.tr(MessageId::SetupActionContinue)),
3653 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
3654 ],
3655 );
3656 // Slice D explicit-consent gate, Gate 1 of 2: choose access. The
3657 // consent backend (#5779) and every localized string are unchanged —
3658 // only the gate framing and the clickable rows are new.
3659 self.consent_row_hitboxes.borrow_mut().clear();
3660 let selected = self.external_consent_choice;
3661 let options = [
3662 (
3663 ExternalConsentChoice::Disabled,
3664 '1',
3665 self.tr(MessageId::ProviderExternalDisabledLabel),
3666 self.tr(MessageId::ProviderExternalDisabledDetail),
3667 ),
3668 (
3669 ExternalConsentChoice::ReadOnly,
3670 '2',
3671 self.tr(MessageId::ProviderExternalReadOnlyLabel),
3672 self.tr(MessageId::ProviderExternalReadOnlyDetail),
3673 ),
3674 (
3675 ExternalConsentChoice::ManagedUnavailable,
3676 '3',
3677 self.tr(MessageId::ProviderExternalManagedLabel),
3678 self.tr(MessageId::ProviderExternalManagedDetail),
3679 ),
3680 ];
3681 // Options render first at fixed rows (header + one line per
3682 // option) so hitboxes stay exact; the wrapping explainer lines live
3683 // below where wrapping cannot disturb pointer geometry.
3684 let mut lines = vec![Line::from(Span::styled(
3685 "Gate 1 of 2 · choose access",
3686 Style::default()
3687 .fg(palette::TEXT_PRIMARY)
3688 .add_modifier(Modifier::BOLD),
3689 ))];
3690 for (slot, (choice, digit, label, detail)) in options.iter().enumerate() {
3691 let is_selected = selected == *choice;
3692 let is_hovered = self.hovered_consent_idx == Some(slot);
3693 let marker = crate::tui::glyphs::selection_marker(is_selected);
3694 let label_style = if is_selected {
3695 menu_style::selected_row_style_with_fg(palette::WHALE_ACTION)
3696 } else if is_hovered {
3697 menu_style::hovered_row_style()
3698 } else {
3699 Style::default().fg(palette::TEXT_PRIMARY)
3700 };
3701 let mut label_line = Line::from(vec![
3702 Span::styled(format!("{marker} {digit}. {label}"), label_style),
3703 Span::styled(
3704 format!(" · {detail}"),
3705 Style::default().fg(palette::TEXT_MUTED),
3706 ),
3707 ]);
3708 if is_selected {
3709 label_line.style = menu_style::selected_row_bg_style();
3710 }
3711 lines.push(label_line);
3712 let row_y = content.y.saturating_add(1 + slot as u16);
3713 self.consent_row_hitboxes
3714 .borrow_mut()
3715 .push((Rect::new(content.x, row_y, content.width, 1), slot));
3716 }
3717 lines.push(Line::from(""));
3718 lines.push(Line::from(Span::styled(
3719 self.tr(MessageId::ProviderExternalChoiceIntro),
3720 Style::default().fg(palette::TEXT_MUTED),
3721 )));
3722 lines.push(Line::from(Span::styled(
3723 "Enter continues · nothing is read or saved until Gate 2 confirms.",
3724 Style::default().fg(palette::TEXT_MUTED),
3725 )));
3726 Paragraph::new(lines)
3727 .wrap(Wrap { trim: true })
3728 .render(content, buf);
3729 }
3730
3731 fn render_external_consent_confirm(&self, area: Rect, buf: &mut Buffer) {
3732 let Some((provider, source, path)) = self.selected_external_consent_target() else {
3733 return;
3734 };
3735 let outer = Block::default()
3736 .title(Line::from(Span::styled(
3737 self.tr(MessageId::ProviderExternalConfirmTitle),
3738 Style::default()
3739 .fg(palette::WHALE_ACTION)
3740 .add_modifier(Modifier::BOLD),
3741 )))
3742 .borders(Borders::ALL)
3743 .border_style(Style::default().fg(palette::BORDER_COLOR))
3744 .style(Style::default().bg(palette::WHALE_BG));
3745 let inner = outer.inner(area);
3746 outer.render(area, buf);
3747 let content = render_modal_footer(
3748 inner,
3749 buf,
3750 &[
3751 ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionGrant)),
3752 ActionHint::new("Esc", self.tr(MessageId::SetupActionCancel)),
3753 ],
3754 );
3755 let provider_label = self.tr(MessageId::RouteProviderLabel);
3756 let route_label = self.tr(MessageId::ProviderExternalRouteLabel);
3757 let owner_label = self.tr(MessageId::ProviderExternalOwnerLabel);
3758 let exact_path_label = self.tr(MessageId::ProviderExternalExactPathLabel);
3759 let semantics_label = self.tr(MessageId::ProviderExternalSemanticsLabel);
3760 let revoke_label = self.tr(MessageId::ProviderExternalRevokeLabel);
3761 // #5772: the disclosure names the exact route (endpoint + default
3762 // model), the source adapter, the custody boundary (local device
3763 // only), the credential/billing owner, and the local-only revoke
3764 // consequence before any validate/read/persist may run.
3765 let row = &self.rows[self.selected_idx];
3766 Paragraph::new(vec![
3767 // Slice D explicit-consent gate, Gate 2 of 2: review the exact
3768 // disclosure before granting. Backend (#5779) unchanged.
3769 Line::from(Span::styled(
3770 "Gate 2 of 2 · review before granting",
3771 Style::default()
3772 .fg(palette::TEXT_PRIMARY)
3773 .add_modifier(Modifier::BOLD),
3774 )),
3775 Line::from(""),
3776 Line::from(format!("{provider_label}: {}", provider.as_str())),
3777 Line::from(format!(
3778 "{route_label}: {} · {}",
3779 row.base_url, row.default_route.logical_model
3780 )),
3781 Line::from(format!(
3782 "{owner_label}: {} ({})",
3783 source.owner_label(),
3784 source.as_str()
3785 )),
3786 Line::from(format!(
3787 "{exact_path_label}: {}",
3788 codewhale_config::quote_os_path(&path)
3789 )),
3790 Line::from(""),
3791 Line::from(format!(
3792 "{semantics_label}: {}.",
3793 self.tr(MessageId::ProviderExternalReadOnlySemantics)
3794 )),
3795 Line::from(self.tr(MessageId::ProviderExternalCustodyLine).into_owned()),
3796 Line::from(
3797 self.tr(MessageId::ProviderExternalBillingLine)
3798 .replace("{owner}", source.owner_label()),
3799 ),
3800 Line::from(self.tr(MessageId::ProviderExternalRejectUnsafe)),
3801 Line::from(
3802 self.tr(MessageId::ProviderExternalRevokeScope)
3803 .replace("{owner}", source.owner_label()),
3804 ),
3805 Line::from(format!(
3806 "{revoke_label}: codewhale auth external-revoke --provider {}",
3807 provider.as_str()
3808 )),
3809 ])
3810 .wrap(Wrap { trim: false })
3811 .render(content, buf);
3812 }
3813
3814 /// Revocation disclosure (#5772): names what is cleared (only the
3815 /// Codewhale-owned consent record on this device) and what is never
3816 /// touched (the external CLI's file). Enter revokes; Esc goes back.
3817 fn render_external_consent_revoke_confirm(&self, area: Rect, buf: &mut Buffer) {
3818 let provider_name = self.rows[self.selected_idx].display_name.clone();
3819 let outer = Block::default()
3820 .title(Line::from(Span::styled(
3821 self.tr(MessageId::ProviderExternalRevokeConfirmTitle),
3822 Style::default()
3823 .fg(palette::WHALE_ACTION)
3824 .add_modifier(Modifier::BOLD),
3825 )))
3826 .borders(Borders::ALL)
3827 .border_style(Style::default().fg(palette::BORDER_COLOR))
3828 .style(Style::default().bg(palette::WHALE_BG));
3829 let inner = outer.inner(area);
3830 outer.render(area, buf);
3831 let content = render_modal_footer(
3832 inner,
3833 buf,
3834 &[
3835 ActionHint::new("Enter", self.tr(MessageId::ProviderExternalActionRevoke)),
3836 ActionHint::new("Esc", self.tr(MessageId::SetupActionBack)),
3837 ],
3838 );
3839 let provider_label = self.tr(MessageId::RouteProviderLabel);
3840 let owner = self
3841 .rows
3842 .get(self.selected_idx)
3843 .and_then(|row| row.external_credential_status.as_ref())
3844 .map(|status| status.owner)
3845 .unwrap_or(self.selected_provider().provider().display_name());
3846 Paragraph::new(vec![
3847 Line::from(format!("{provider_label}: {provider_name}")),
3848 Line::from(
3849 self.tr(MessageId::ProviderExternalRevokeScope)
3850 .replace("{owner}", owner),
3851 ),
3852 ])
3853 .wrap(Wrap { trim: false })
3854 .render(content, buf);
3855 }
3856
3857 fn render_model_pick(&self, area: Rect, buf: &mut Buffer) {
3858 let provider_name = self.rows[self.selected_idx].display_name.clone();
3859 let outer = Block::default()
3860 .title(Line::from(Span::styled(
3861 format!(" Default model · {provider_name} "),
3862 Style::default()
3863 .fg(palette::WHALE_ACTION)
3864 .add_modifier(Modifier::BOLD),
3865 )))
3866 .borders(Borders::ALL)
3867 .border_style(Style::default().fg(palette::BORDER_COLOR))
3868 .style(Style::default().bg(palette::WHALE_BG));
3869 let inner = outer.inner(area);
3870 outer.render(area, buf);
3871
3872 let content = render_modal_footer(
3873 inner,
3874 buf,
3875 &[
3876 ActionHint::new("↑↓", "move"),
3877 ActionHint::new("Enter", "continue"),
3878 ActionHint::new("Esc", "back"),
3879 ],
3880 );
3881
3882 let header = Paragraph::new(Line::from(Span::styled(
3883 self.tr(MessageId::ProviderConnectionCheckedPickModel),
3884 Style::default().fg(palette::TEXT_MUTED),
3885 )));
3886 let layout = Layout::default()
3887 .direction(Direction::Vertical)
3888 .constraints([Constraint::Length(1), Constraint::Min(1)])
3889 .split(content);
3890 header.render(layout[0], buf);
3891
3892 let list_area = layout[1];
3893 let visible_rows = usize::from(list_area.height);
3894 let visible_start = Self::visible_start(
3895 self.model_selected_idx,
3896 self.model_options.len(),
3897 visible_rows,
3898 );
3899 // Slice D: every model row carries its own $/mtok from the catalog
3900 // and every visible row is clickable, so record this frame's geometry
3901 // for hover + click handling.
3902 self.model_row_hitboxes.borrow_mut().clear();
3903 let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
3904 for (idx, model) in self
3905 .model_options
3906 .iter()
3907 .enumerate()
3908 .skip(visible_start)
3909 .take(visible_rows)
3910 {
3911 let is_selected = idx == self.model_selected_idx;
3912 let is_hovered = self.hovered_model_idx == Some(idx);
3913 let arrow = crate::tui::glyphs::selection_marker(is_selected);
3914 let label_style = if is_selected {
3915 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
3916 } else if is_hovered {
3917 menu_style::hovered_row_style()
3918 } else {
3919 Style::default().fg(palette::TEXT_PRIMARY)
3920 };
3921 let default_tag = if self.rows[self.selected_idx]
3922 .default_route
3923 .logical_model
3924 .eq_ignore_ascii_case(model)
3925 {
3926 "default"
3927 } else {
3928 ""
3929 };
3930 // Slice D: cost moved off the provider level down to the model.
3931 let price = configured_model_cost_label(
3932 &self.route_config,
3933 &self.rows[self.selected_idx],
3934 model,
3935 );
3936 let mut spans = vec![
3937 Span::styled(format!(" {arrow} {model}"), label_style),
3938 Span::styled(
3939 format!(" {price}"),
3940 if is_selected {
3941 menu_style::selected_row_style_with_fg(palette::TEXT_MUTED)
3942 } else {
3943 Style::default().fg(palette::TEXT_MUTED)
3944 },
3945 ),
3946 ];
3947 if !default_tag.is_empty() {
3948 spans.push(Span::styled(
3949 format!(" ({default_tag})"),
3950 if is_selected {
3951 menu_style::selected_row_style_with_fg(palette::TEXT_MUTED)
3952 } else {
3953 Style::default().fg(palette::TEXT_MUTED)
3954 },
3955 ));
3956 }
3957 let mut line = Line::from(spans);
3958 if is_selected {
3959 line.style = menu_style::selected_row_bg_style();
3960 }
3961 let row_y = list_area.y.saturating_add(lines.len() as u16);
3962 self.model_row_hitboxes
3963 .borrow_mut()
3964 .push((Rect::new(list_area.x, row_y, list_area.width, 1), idx));
3965 lines.push(line);
3966 }
3967 if lines.is_empty() {
3968 lines.push(Line::from(Span::styled(
3969 self.tr(MessageId::ProviderNoCatalogModels),
3970 Style::default().fg(palette::TEXT_MUTED),
3971 )));
3972 }
3973 Paragraph::new(lines).render(list_area, buf);
3974 }
3975
3976 fn render_plan_tier(&self, area: Rect, buf: &mut Buffer) {
3977 let outer = Block::default()
3978 .title(Line::from(Span::styled(
3979 " Kimi Code plan tier ",
3980 Style::default()
3981 .fg(palette::WHALE_ACTION)
3982 .add_modifier(Modifier::BOLD),
3983 )))
3984 .borders(Borders::ALL)
3985 .border_style(Style::default().fg(palette::BORDER_COLOR))
3986 .style(Style::default().bg(palette::WHALE_BG));
3987 let inner = outer.inner(area);
3988 outer.render(area, buf);
3989 let content = render_modal_footer(
3990 inner,
3991 buf,
3992 &[
3993 ActionHint::new("↑↓", "choose"),
3994 ActionHint::new("Enter", "continue"),
3995 ActionHint::new("Esc", "back"),
3996 ],
3997 );
3998 self.render_setup_choices(
3999 content,
4000 buf,
4001 vec![
4002 Line::from("Kimi Code plan limits determine the context window used for k3."),
4003 Line::from(
4004 "Choose the tier you actually have; the safe floor is selected by default.",
4005 ),
4006 ],
4007 [
4008 "262K context (safe default)".into(),
4009 "1M context (only with an eligible plan)".into(),
4010 ],
4011 usize::from(self.kimi_code_plan_tier == KimiCodePlanTier::OneMillion),
4012 );
4013 }
4014
4015 fn render_stepfun_billing_route(&self, area: Rect, buf: &mut Buffer) {
4016 let outer = Block::default()
4017 .title(Line::from(Span::styled(
4018 format!(" {} ", self.tr(MessageId::StepfunBillingRouteTitle)),
4019 Style::default()
4020 .fg(palette::WHALE_ACTION)
4021 .add_modifier(Modifier::BOLD),
4022 )))
4023 .borders(Borders::ALL)
4024 .border_style(Style::default().fg(palette::BORDER_COLOR))
4025 .style(Style::default().bg(palette::WHALE_BG));
4026 let inner = outer.inner(area);
4027 outer.render(area, buf);
4028 let content = render_modal_footer(
4029 inner,
4030 buf,
4031 &[
4032 ActionHint::new("↑↓", "choose"),
4033 ActionHint::new("Enter", "continue"),
4034 ActionHint::new("Esc", "back"),
4035 ],
4036 );
4037 // Keep the actual endpoint with each route; choosing only stages
4038 // the value and still follows the existing confirmation flow.
4039 self.render_setup_choices(
4040 content,
4041 buf,
4042 vec![Line::from(self.tr(MessageId::StepfunBillingRouteIntro))],
4043 [
4044 format!(
4045 "{} — {}",
4046 self.tr(MessageId::StepfunBillingRoutePaygOption),
4047 StepfunBillingRoute::PayAsYouGo.base_url()
4048 ),
4049 format!(
4050 "{} — {}",
4051 self.tr(MessageId::StepfunBillingRoutePlanOption),
4052 StepfunBillingRoute::StepPlan.base_url()
4053 ),
4054 ],
4055 usize::from(self.stepfun_billing_route == StepfunBillingRoute::StepPlan),
4056 );
4057 }
4058
4059 /// One geometry for one- or two-choice setup screens. Pointer hitboxes
4060 /// cover only painted rows, including wrapped labels; no auth or billing
4061 /// action lives here. Small terminals give options room before prose.
4062 fn render_setup_choices<const N: usize>(
4063 &self,
4064 area: Rect,
4065 buf: &mut Buffer,
4066 intro: Vec<Line<'static>>,
4067 labels: [String; N],
4068 selected: usize,
4069 ) {
4070 self.choice_row_hitboxes.borrow_mut().clear();
4071 if area.width == 0 || area.height == 0 {
4072 return;
4073 }
4074 let choices: Vec<_> = labels
4075 .into_iter()
4076 .enumerate()
4077 .map(|(idx, label)| {
4078 let key = if idx == 0 { '1' } else { '2' };
4079 let style = if selected == idx {
4080 menu_style::selected_row_style()
4081 } else if self.hovered_choice == Some(key) {
4082 menu_style::hovered_row_style().fg(palette::TEXT_PRIMARY)
4083 } else {
4084 Style::default().fg(palette::TEXT_PRIMARY)
4085 };
4086 Paragraph::new(format!(
4087 "{} {key}. {label}",
4088 crate::tui::glyphs::selection_marker(selected == idx)
4089 ))
4090 .style(style)
4091 .wrap(Wrap { trim: false })
4092 })
4093 .collect();
4094 let needed = choices
4095 .iter()
4096 .map(|p| p.line_count(area.width) as u16)
4097 .sum::<u16>();
4098 let intro = Paragraph::new(intro)
4099 .style(Style::default().fg(palette::TEXT_MUTED))
4100 .wrap(Wrap { trim: false });
4101 let intro_height =
4102 (intro.line_count(area.width) as u16).min(area.height.saturating_sub(needed));
4103 intro.render(Rect::new(area.x, area.y, area.width, intro_height), buf);
4104 let mut y = area.y + intro_height;
4105 for (idx, choice) in choices.into_iter().enumerate() {
4106 let remaining = area.bottom().saturating_sub(y);
4107 let reserve = u16::from(idx + 1 < N && remaining > 1);
4108 let height =
4109 (choice.line_count(area.width) as u16).min(remaining.saturating_sub(reserve));
4110 if height == 0 {
4111 continue;
4112 }
4113 let row = Rect::new(area.x, y, area.width, height);
4114 choice.render(row, buf);
4115 self.choice_row_hitboxes
4116 .borrow_mut()
4117 .push((row, if idx == 0 { '1' } else { '2' }));
4118 y += height;
4119 }
4120 }
4121
4122 fn render_confirm(&self, area: Rect, buf: &mut Buffer) {
4123 let row = &self.rows[self.selected_idx];
4124 let outer = Block::default()
4125 .title(Line::from(Span::styled(
4126 " Confirm provider setup ",
4127 Style::default()
4128 .fg(palette::WHALE_ACTION)
4129 .add_modifier(Modifier::BOLD),
4130 )))
4131 .borders(Borders::ALL)
4132 .border_style(Style::default().fg(palette::BORDER_COLOR))
4133 .style(Style::default().bg(palette::WHALE_BG));
4134 let inner = outer.inner(area);
4135 outer.render(area, buf);
4136
4137 let content = render_modal_footer(
4138 inner,
4139 buf,
4140 &[
4141 ActionHint::new("Enter", "save & switch"),
4142 ActionHint::new("Esc", "back"),
4143 ],
4144 );
4145
4146 let masked = self
4147 .pending_api_key
4148 .as_deref()
4149 .map(mask_key)
4150 .filter(|value| !value.is_empty())
4151 .unwrap_or_else(|| "(none)".to_string());
4152 let model = self
4153 .selected_model
4154 .as_deref()
4155 .filter(|value| !value.trim().is_empty())
4156 .unwrap_or("(none)");
4157 let lines = vec![
4158 Line::from(Span::styled(
4159 "Review before saving. Nothing is written until you confirm.",
4160 Style::default().fg(palette::TEXT_MUTED),
4161 )),
4162 Line::from(vec![
4163 Span::styled("Provider: ", Style::default().fg(palette::TEXT_MUTED)),
4164 Span::styled(
4165 row.display_name.clone(),
4166 Style::default()
4167 .fg(palette::TEXT_PRIMARY)
4168 .add_modifier(Modifier::BOLD),
4169 ),
4170 ]),
4171 Line::from(vec![
4172 Span::styled("API key: ", Style::default().fg(palette::TEXT_MUTED)),
4173 Span::styled(masked, Style::default().fg(palette::TEXT_PRIMARY)),
4174 ]),
4175 Line::from(vec![
4176 Span::styled("Model: ", Style::default().fg(palette::TEXT_MUTED)),
4177 Span::styled(
4178 model.to_string(),
4179 Style::default()
4180 .fg(palette::TEXT_PRIMARY)
4181 .add_modifier(Modifier::BOLD),
4182 ),
4183 ]),
4184 if let Some(context_window) = self.selected_context_window {
4185 Line::from(format!("Context: {} tokens", context_window))
4186 } else {
4187 Line::from("")
4188 },
4189 ];
4190 Paragraph::new(lines).render(content, buf);
4191 }
4192
4193 fn render_custom_form(&self, area: Rect, buf: &mut Buffer) {
4194 let title = " Custom provider ".to_string();
4195 let outer = Block::default()
4196 .title(Line::from(Span::styled(
4197 title,
4198 Style::default()
4199 .fg(palette::WHALE_ACTION)
4200 .add_modifier(Modifier::BOLD),
4201 )))
4202 .borders(Borders::ALL)
4203 .border_style(Style::default().fg(palette::BORDER_COLOR))
4204 .style(Style::default().bg(palette::WHALE_BG));
4205 let inner = outer.inner(area);
4206 outer.render(area, buf);
4207
4208 let content = render_modal_footer(
4209 inner,
4210 buf,
4211 &[
4212 ActionHint::new("Tab/↑↓", "field"),
4213 ActionHint::new("Enter", "next/save"),
4214 ActionHint::new("Esc", "back"),
4215 ],
4216 );
4217 let layout = Layout::default()
4218 .direction(Direction::Vertical)
4219 .constraints([
4220 Constraint::Length(1),
4221 Constraint::Length(1),
4222 Constraint::Length(1),
4223 Constraint::Length(1),
4224 Constraint::Length(1),
4225 Constraint::Min(0),
4226 ])
4227 .split(content);
4228
4229 let hint = self.tr(MessageId::ProviderCustomFormHint).into_owned();
4230 Paragraph::new(Line::from(Span::styled(
4231 hint,
4232 Style::default().fg(palette::TEXT_MUTED),
4233 )))
4234 .wrap(Wrap { trim: true })
4235 .render(layout[0], buf);
4236
4237 self.render_custom_form_field(layout[1], buf, CustomProviderField::Name, "Name", "acme_ai");
4238 self.render_custom_form_field(
4239 layout[2],
4240 buf,
4241 CustomProviderField::BaseUrl,
4242 &self.tr(MessageId::ProviderCustomFormBaseUrl),
4243 "https://api.example.com/v1",
4244 );
4245 self.render_custom_form_field(
4246 layout[3],
4247 buf,
4248 CustomProviderField::Model,
4249 &self.tr(MessageId::ProviderCustomFormModel),
4250 "optional",
4251 );
4252 self.render_custom_form_field(
4253 layout[4],
4254 buf,
4255 CustomProviderField::ApiKeyEnv,
4256 "API key env",
4257 "optional",
4258 );
4259 if let Some(descriptor) = self.custom_provider_descriptor {
4260 Paragraph::new(descriptor_help_lines(descriptor))
4261 .wrap(Wrap { trim: true })
4262 .render(layout[5], buf);
4263 }
4264 }
4265
4266 fn render_custom_form_field(
4267 &self,
4268 area: Rect,
4269 buf: &mut Buffer,
4270 field: CustomProviderField,
4271 label: &str,
4272 placeholder: &str,
4273 ) {
4274 let selected = self.custom_provider_field == field;
4275 let marker = crate::tui::glyphs::selection_marker(selected);
4276 let value = self.custom_form_field_value(field);
4277 let display = if value.is_empty() { placeholder } else { value };
4278 let value_style = if selected {
4279 menu_style::selected_row_style_with_fg(palette::SELECTION_TEXT)
4280 } else if value.is_empty() {
4281 Style::default().fg(palette::TEXT_MUTED)
4282 } else {
4283 Style::default().fg(palette::TEXT_PRIMARY)
4284 };
4285 let label_style = if selected {
4286 menu_style::selected_row_style_with_fg(palette::WHALE_ACTION)
4287 } else {
4288 Style::default().fg(palette::TEXT_MUTED)
4289 };
4290 let mut line = Line::from(vec![
4291 Span::styled(marker, label_style),
4292 Span::styled(" ", label_style),
4293 Span::styled(format!("{label}: "), label_style),
4294 Span::styled(
4295 crate::tui::ui_text::truncate_line_to_width(
4296 display,
4297 usize::from(area.width).saturating_sub(18),
4298 ),
4299 value_style,
4300 ),
4301 ]);
4302 if selected {
4303 line.style = menu_style::selected_row_bg_style();
4304 }
4305 Paragraph::new(line).render(area, buf);
4306 }
4307
4308 /// Slice D two-pane pointer support: hover is advisory (it never moves
4309 /// the keyboard selection) and renders with the shared
4310 /// [`crate::tui::menu_style::hovered_row_style`] primitive; click selects
4311 /// and a second click activates, mirroring the model picker rhythm.
4312 fn list_hit_at(&self, mouse: MouseEvent) -> Option<usize> {
4313 let pos = Position::new(mouse.column, mouse.row);
4314 self.list_row_hitboxes
4315 .borrow()
4316 .iter()
4317 .find_map(|(rect, idx)| rect.contains(pos).then_some(*idx))
4318 }
4319
4320 fn model_hit_at(&self, mouse: MouseEvent) -> Option<usize> {
4321 let pos = Position::new(mouse.column, mouse.row);
4322 self.model_row_hitboxes
4323 .borrow()
4324 .iter()
4325 .find_map(|(rect, idx)| rect.contains(pos).then_some(*idx))
4326 }
4327
4328 fn consent_hit_at(&self, mouse: MouseEvent) -> Option<usize> {
4329 let pos = Position::new(mouse.column, mouse.row);
4330 self.consent_row_hitboxes
4331 .borrow()
4332 .iter()
4333 .find_map(|(rect, slot)| rect.contains(pos).then_some(*slot))
4334 }
4335
4336 /// Enter on the list stage, shared by keyboard and double-click so both
4337 /// paths apply, set up, or route to the custom form identically.
4338 fn activate_selected_row(&mut self) -> ViewAction {
4339 if !self.row_visible(self.selected_idx) {
4340 return ViewAction::None;
4341 }
4342 let provider = self.selected_provider();
4343 if provider == ProviderKind::Custom && !self.rows[self.selected_idx].is_configured {
4344 // A bundled-descriptor row already knows the host; only the blank
4345 // `Custom` placeholder starts from an empty form.
4346 match provider_descriptor(&self.rows[self.selected_idx].provider_id) {
4347 Some(descriptor) => self.enter_descriptor_form(descriptor),
4348 None => self.enter_custom_form(),
4349 }
4350 ViewAction::None
4351 } else if self.selected_identity().is_none() {
4352 ViewAction::None
4353 } else if provider == ProviderKind::OpenaiCodex && !self.selected_has_key() {
4354 self.enter_chatgpt_auth_choice();
4355 ViewAction::None
4356 } else if !self.selected_route_is_valid() {
4357 ViewAction::None
4358 } else if self.selected_has_key() && !self.selected_credential_rejected() {
4359 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
4360 identity: self.selected_identity().expect("checked admitted row"),
4361 })
4362 } else {
4363 // #5772: plain activation never inspects or adopts an external
4364 // CLI credential. Reuse starts only from the explicit `e` action,
4365 // which discloses the exact path and requires its own
4366 // confirmation.
4367 self.begin_setup();
4368 ViewAction::None
4369 }
4370 }
4371
4372 /// Enter on the model-pick stage, shared by keyboard and double-click.
4373 fn advance_from_model_pick(&mut self) -> ViewAction {
4374 if self.model_options.is_empty() {
4375 return ViewAction::None;
4376 }
4377 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
4378 if self.selected_kimi_code_k3() {
4379 self.enter_plan_tier();
4380 } else {
4381 self.enter_confirm();
4382 }
4383 ViewAction::None
4384 }
4385
4386 fn click_list_row(&mut self, mouse: MouseEvent) -> ViewAction {
4387 let Some(idx) = self.list_hit_at(mouse) else {
4388 return ViewAction::None;
4389 };
4390 let activate = self.last_list_mouse_selected == Some(idx) && self.selected_idx == idx;
4391 self.selected_idx = idx;
4392 self.last_list_mouse_selected = Some(idx);
4393 if activate {
4394 self.activate_selected_row()
4395 } else {
4396 ViewAction::None
4397 }
4398 }
4399
4400 fn click_model_row(&mut self, mouse: MouseEvent) -> ViewAction {
4401 let Some(idx) = self.model_hit_at(mouse) else {
4402 return ViewAction::None;
4403 };
4404 let advance = self.last_model_mouse_selected == Some(idx) && self.model_selected_idx == idx;
4405 self.model_selected_idx = idx.min(self.model_options.len().saturating_sub(1));
4406 self.selected_model = self.model_options.get(self.model_selected_idx).cloned();
4407 self.last_model_mouse_selected = Some(idx);
4408 if advance {
4409 self.advance_from_model_pick()
4410 } else {
4411 ViewAction::None
4412 }
4413 }
4414
4415 fn click_consent_row(&mut self, mouse: MouseEvent) {
4416 let Some(slot) = self.consent_hit_at(mouse) else {
4417 return;
4418 };
4419 // Single click chooses; Enter still commits, so a stray click can
4420 // never grant or revoke access by itself.
4421 self.external_consent_choice = match slot {
4422 0 => ExternalConsentChoice::Disabled,
4423 1 => ExternalConsentChoice::ReadOnly,
4424 _ => ExternalConsentChoice::ManagedUnavailable,
4425 };
4426 }
4427 }
4428
4429 fn mask_key(input: &str) -> String {
4430 let trimmed = input.trim();
4431 let len = trimmed.chars().count();
4432 if len == 0 {
4433 return String::new();
4434 }
4435 if len <= 4 {
4436 return "*".repeat(len);
4437 }
4438 let visible: String = trimmed
4439 .chars()
4440 .rev()
4441 .take(4)
4442 .collect::<String>()
4443 .chars()
4444 .rev()
4445 .collect();
4446 format!("{}{}", "*".repeat(len - 4), visible)
4447 }
4448
4449 impl ModalView for ProviderPickerView {
4450 fn kind(&self) -> ModalKind {
4451 ModalKind::ProviderPicker
4452 }
4453
4454 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
4455 self
4456 }
4457
4458 fn handle_paste(&mut self, text: &str) -> bool {
4459 match self.stage {
4460 Stage::KeyEntry => {
4461 if self.key_entry_is_oauth_locked() {
4462 return true;
4463 }
4464 let sanitized: String = text.chars().filter(|c| !c.is_whitespace()).collect();
4465 if !sanitized.is_empty() {
4466 self.api_key_input.push_str(&sanitized);
4467 self.key_entry_error = None;
4468 }
4469 true
4470 }
4471 Stage::CustomForm => {
4472 let sanitized = text.replace(['\r', '\n', '\t'], " ");
4473 self.custom_form_field_mut().push_str(sanitized.trim());
4474 true
4475 }
4476 Stage::List if self.search_mode || !self.query.is_empty() => {
4477 let sanitized = text.replace(['\r', '\n', '\t'], " ");
4478 self.update_query(format!("{}{}", self.query, sanitized));
4479 true
4480 }
4481 Stage::List
4482 | Stage::XaiAuthChoice
4483 | Stage::ChatgptAuthChoice
4484 | Stage::ExternalConsentChoice
4485 | Stage::ExternalConsentConfirm
4486 | Stage::ExternalConsentRevokeConfirm
4487 | Stage::ModelPick
4488 | Stage::PlanTier
4489 | Stage::StepfunBillingRoute
4490 | Stage::Confirm => false,
4491 }
4492 }
4493
4494 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
4495 self.interacted = true;
4496 self.last_choice_mouse_selected = None;
4497 self.hovered_choice = None;
4498 if self.stage == Stage::List
4499 && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
4500 {
4501 match key.code {
4502 KeyCode::Char('/') if !self.search_mode && self.query.is_empty() => {
4503 self.search_mode = true;
4504 return ViewAction::None;
4505 }
4506 KeyCode::Char(ch) if self.search_mode => {
4507 let mut query = self.query.clone();
4508 query.push(ch);
4509 self.update_query(query);
4510 return ViewAction::None;
4511 }
4512 _ => {}
4513 }
4514 }
4515 match self.stage {
4516 Stage::List => match key.code {
4517 _ if crate::tui::shell_key_routing::is_tool_details_shortcut(&key) => {
4518 self.open_provider_details()
4519 }
4520 KeyCode::Esc if self.search_mode || !self.query.is_empty() => {
4521 self.search_mode = false;
4522 self.update_query(String::new());
4523 ViewAction::None
4524 }
4525 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
4526 catalog_view: self.view == ProviderListView::Catalog,
4527 selected_provider_id: self
4528 .rows
4529 .get(self.selected_idx)
4530 .map(|row| row.provider_id.clone()),
4531 }),
4532 // One movement vocabulary (#6290): `list_nav` classifies the
4533 // keys; this surface owns only what a motion means for its
4534 // rows. A surface with a live filter uses the typing-safe key
4535 // set — no letter aliases to eat the query.
4536 KeyCode::Up
4537 | KeyCode::Down
4538 | KeyCode::PageUp
4539 | KeyCode::PageDown
4540 | KeyCode::Home
4541 | KeyCode::End
4542 if key.modifiers.is_empty() =>
4543 {
4544 self.move_by_list_motion(&key);
4545 ViewAction::None
4546 }
4547 // Row-dependent actions are no-ops when the current filter
4548 // (#3830) hides every row — e.g. a fresh Configured view
4549 // with nothing configured yet shows the empty state and
4550 // `selected_idx` doesn't point at anything on screen.
4551 // Keyboard and double-click share `activate_selected_row`
4552 // (Slice D) so both paths behave identically.
4553 KeyCode::Enter => self.activate_selected_row(),
4554 KeyCode::Char(c)
4555 if key.modifiers.is_empty()
4556 && self.query.is_empty()
4557 && c.eq_ignore_ascii_case(&'x')
4558 && self.row_visible(self.selected_idx)
4559 && self.rows[self.selected_idx].credential_state
4560 == CredentialState::ExternalConsent =>
4561 {
4562 // #5772: revoking external access asks first; only an
4563 // affirmative Enter on the revoke confirmation clears the
4564 // Codewhale-owned consent record.
4565 self.external_revoke_return = Stage::List;
4566 self.stage = Stage::ExternalConsentRevokeConfirm;
4567 ViewAction::None
4568 }
4569 KeyCode::Char(c)
4570 if key.modifiers.is_empty()
4571 && c.eq_ignore_ascii_case(&'e')
4572 && self.query.is_empty()
4573 && self.row_visible(self.selected_idx)
4574 && provider_supports_external_consent(self.selected_provider()) =>
4575 {
4576 if self.selected_provider() == ProviderKind::OpenaiCodex {
4577 self.enter_chatgpt_auth_choice();
4578 } else {
4579 // #5772: disclosure precedes the exact external read
4580 // grant for providers that support CLI reuse.
4581 self.enter_external_consent_choice();
4582 }
4583 ViewAction::None
4584 }
4585 KeyCode::Char(c)
4586 if key.modifiers.is_empty()
4587 && c.eq_ignore_ascii_case(&'r')
4588 && self.query.is_empty()
4589 && self.row_visible(self.selected_idx) =>
4590 {
4591 self.begin_setup();
4592 ViewAction::None
4593 }
4594 // Toggle between the configured-only default view and the
4595 // full provider catalog (#3830). Handled before the
4596 // type-ahead arm so `a`/`A` always toggles instead of
4597 // seeking a provider whose name starts with "a".
4598 KeyCode::Char(c)
4599 if key.modifiers.is_empty()
4600 && self.query.is_empty()
4601 && c.eq_ignore_ascii_case(&'a') =>
4602 {
4603 self.toggle_view();
4604 ViewAction::None
4605 }
4606 KeyCode::Char(c)
4607 if key.modifiers.is_empty()
4608 && self.query.is_empty()
4609 && c.eq_ignore_ascii_case(&'l') =>
4610 {
4611 self.show_local_routes();
4612 ViewAction::None
4613 }
4614 KeyCode::Char(c)
4615 if key.modifiers.is_empty()
4616 && self.query.is_empty()
4617 && c.eq_ignore_ascii_case(&'i') =>
4618 {
4619 self.enter_lm_studio_form();
4620 ViewAction::None
4621 }
4622 KeyCode::Char(c)
4623 if key.modifiers.is_empty()
4624 && self.query.is_empty()
4625 && c.eq_ignore_ascii_case(&'c') =>
4626 {
4627 self.enter_custom_form();
4628 ViewAction::None
4629 }
4630 KeyCode::Char(c)
4631 if key.modifiers.is_empty()
4632 && self.query.is_empty()
4633 && c.eq_ignore_ascii_case(&'d') =>
4634 {
4635 self.enter_ds4_form();
4636 ViewAction::None
4637 }
4638 KeyCode::Char(c)
4639 if key.modifiers.contains(KeyModifiers::CONTROL)
4640 && c.eq_ignore_ascii_case(&'t')
4641 && self.row_visible(self.selected_idx) =>
4642 {
4643 let Some(identity) = self.selected_identity() else {
4644 return ViewAction::None;
4645 };
4646 ViewAction::EmitAndClose(ViewEvent::ProviderPickerTestConnection {
4647 identity,
4648 catalog_view: self.view == ProviderListView::Catalog,
4649 })
4650 }
4651 // Jump to the `/model` picker pre-filtered to this provider
4652 // (#3083). Handled before the type-ahead arm so `m`/`M` opens
4653 // models instead of seeking a provider whose name starts with m.
4654 KeyCode::Char(c)
4655 if key.modifiers.is_empty()
4656 && self.query.is_empty()
4657 && c.eq_ignore_ascii_case(&'m')
4658 && self.row_visible(self.selected_idx) =>
4659 {
4660 let Some(identity) = self.selected_identity() else {
4661 return ViewAction::None;
4662 };
4663 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels { identity })
4664 }
4665 KeyCode::Backspace if !self.query.is_empty() => {
4666 let mut query = self.query.clone();
4667 query.pop();
4668 self.update_query(query);
4669 ViewAction::None
4670 }
4671 KeyCode::Char(ch)
4672 if key.modifiers.is_empty()
4673 && !key
4674 .modifiers
4675 .contains(crossterm::event::KeyModifiers::CONTROL) =>
4676 {
4677 let mut query = self.query.clone();
4678 query.push(ch);
4679 self.update_query(query);
4680 ViewAction::None
4681 }
4682 _ => ViewAction::None,
4683 },
4684 Stage::XaiAuthChoice => match key.code {
4685 KeyCode::Esc => {
4686 self.stage = Stage::List;
4687 ViewAction::None
4688 }
4689 KeyCode::Up | KeyCode::Down => {
4690 self.move_xai_auth_choice();
4691 ViewAction::None
4692 }
4693 KeyCode::Char('1') => {
4694 self.xai_auth_choice = XaiAuthChoice::ApiKey;
4695 ViewAction::None
4696 }
4697 KeyCode::Char('2') => {
4698 self.xai_auth_choice = XaiAuthChoice::DeviceOAuth;
4699 ViewAction::None
4700 }
4701 KeyCode::Char(c) if key.modifiers.is_empty() && c.eq_ignore_ascii_case(&'e') => {
4702 self.enter_external_consent_choice();
4703 ViewAction::None
4704 }
4705 KeyCode::Enter => match self.xai_auth_choice {
4706 XaiAuthChoice::ApiKey => {
4707 self.enter_key_entry();
4708 ViewAction::None
4709 }
4710 XaiAuthChoice::DeviceOAuth => {
4711 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
4712 }
4713 },
4714 _ => ViewAction::None,
4715 },
4716 Stage::ChatgptAuthChoice => match key.code {
4717 KeyCode::Esc => {
4718 self.stage = Stage::List;
4719 ViewAction::None
4720 }
4721 KeyCode::Enter => {
4722 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
4723 }
4724 KeyCode::Char(c) if key.modifiers.is_empty() && c.eq_ignore_ascii_case(&'e') => {
4725 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
4726 }
4727 _ => ViewAction::None,
4728 },
4729 Stage::KeyEntry => match key.code {
4730 KeyCode::Esc => {
4731 // Back to the route choice when one was made, so Esc undoes
4732 // one wizard step instead of discarding the whole flow.
4733 self.stage = if self.selected_provider() == ProviderKind::Xai {
4734 Stage::XaiAuthChoice
4735 } else if self.selected_provider() == ProviderKind::OpenaiCodex {
4736 Stage::ChatgptAuthChoice
4737 } else if self.pending_base_url.is_some() {
4738 Stage::StepfunBillingRoute
4739 } else {
4740 Stage::List
4741 };
4742 self.api_key_input.clear();
4743 self.key_entry_error = None;
4744 self.pending_api_key = None;
4745 self.model_options.clear();
4746 self.model_selected_idx = 0;
4747 self.selected_model = None;
4748 ViewAction::None
4749 }
4750 KeyCode::Backspace => {
4751 if !self.key_entry_is_oauth_locked() {
4752 self.api_key_input.pop();
4753 self.key_entry_error = None;
4754 }
4755 ViewAction::None
4756 }
4757 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4758 if !self.key_entry_is_oauth_locked() {
4759 self.api_key_input.pop();
4760 self.key_entry_error = None;
4761 }
4762 ViewAction::None
4763 }
4764 KeyCode::Enter => {
4765 if self.selected_provider() == ProviderKind::OpenaiCodex {
4766 return ViewAction::EmitAndClose(
4767 ViewEvent::ProviderPickerChatgptOAuthRequested,
4768 );
4769 }
4770 let key = self.api_key_input.trim().to_string();
4771 if key.is_empty() {
4772 // Stay in key-entry; the user can press Esc to abort.
4773 ViewAction::None
4774 } else {
4775 let Some(identity) = self.selected_identity() else {
4776 return ViewAction::None;
4777 };
4778 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
4779 identity,
4780 api_key: key,
4781 base_url: self.pending_base_url.clone(),
4782 })
4783 }
4784 }
4785 KeyCode::Char(c)
4786 if !key.modifiers.intersects(
4787 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
4788 ) =>
4789 {
4790 if self.key_entry_is_oauth_locked() {
4791 return ViewAction::None;
4792 }
4793 // Reject ASCII whitespace so a stray space/tab doesn't slip
4794 // into a credential; bracketed paste happens via the input
4795 // path that already trims on submit.
4796 if !c.is_whitespace() {
4797 self.api_key_input.push(c);
4798 self.key_entry_error = None;
4799 }
4800 ViewAction::None
4801 }
4802 _ => ViewAction::None,
4803 },
4804 Stage::ExternalConsentChoice => match key.code {
4805 KeyCode::Esc => {
4806 self.stage = if self.selected_provider() == ProviderKind::Xai {
4807 Stage::XaiAuthChoice
4808 } else if self.selected_provider() == ProviderKind::OpenaiCodex {
4809 Stage::ChatgptAuthChoice
4810 } else {
4811 Stage::KeyEntry
4812 };
4813 ViewAction::None
4814 }
4815 KeyCode::Up => {
4816 self.move_external_consent_choice(-1);
4817 ViewAction::None
4818 }
4819 KeyCode::Down => {
4820 self.move_external_consent_choice(1);
4821 ViewAction::None
4822 }
4823 KeyCode::Char('1') => {
4824 self.external_consent_choice = ExternalConsentChoice::Disabled;
4825 ViewAction::None
4826 }
4827 KeyCode::Char('2') => {
4828 self.external_consent_choice = ExternalConsentChoice::ReadOnly;
4829 ViewAction::None
4830 }
4831 KeyCode::Char('3') => {
4832 self.external_consent_choice = ExternalConsentChoice::ManagedUnavailable;
4833 ViewAction::None
4834 }
4835 KeyCode::Enter => match self.external_consent_choice {
4836 ExternalConsentChoice::Disabled => {
4837 // #5772: revocation is destructive to Codewhale-owned
4838 // state, so it gets its own confirmation instead of a
4839 // one-key commit.
4840 self.external_revoke_return = Stage::ExternalConsentChoice;
4841 self.stage = Stage::ExternalConsentRevokeConfirm;
4842 ViewAction::None
4843 }
4844 ExternalConsentChoice::ReadOnly => {
4845 self.stage = Stage::ExternalConsentConfirm;
4846 ViewAction::None
4847 }
4848 ExternalConsentChoice::ManagedUnavailable => ViewAction::None,
4849 },
4850 _ => ViewAction::None,
4851 },
4852 Stage::ExternalConsentConfirm => match key.code {
4853 KeyCode::Esc => {
4854 self.stage = Stage::ExternalConsentChoice;
4855 ViewAction::None
4856 }
4857 KeyCode::Enter => self
4858 .build_external_consent_event()
4859 .map(ViewAction::EmitAndClose)
4860 .unwrap_or(ViewAction::None),
4861 _ => ViewAction::None,
4862 },
4863 Stage::ExternalConsentRevokeConfirm => match key.code {
4864 KeyCode::Esc => {
4865 self.stage = self.external_revoke_return;
4866 ViewAction::None
4867 }
4868 KeyCode::Enter => {
4869 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
4870 provider: self.selected_provider(),
4871 })
4872 }
4873 _ => ViewAction::None,
4874 },
4875 Stage::ModelPick => match key.code {
4876 KeyCode::Esc => {
4877 // Back to key entry with the validated key pre-filled so the
4878 // user can retype without losing progress.
4879 self.stage = Stage::KeyEntry;
4880 if let Some(pending) = self.pending_api_key.clone() {
4881 self.api_key_input = pending;
4882 }
4883 self.key_entry_error = None;
4884 ViewAction::None
4885 }
4886 KeyCode::Up => {
4887 self.move_model_selection(-1);
4888 ViewAction::None
4889 }
4890 KeyCode::Down => {
4891 self.move_model_selection(1);
4892 ViewAction::None
4893 }
4894 // Keyboard and double-click share `advance_from_model_pick`
4895 // (Slice D) so both paths behave identically.
4896 KeyCode::Enter => self.advance_from_model_pick(),
4897 _ => ViewAction::None,
4898 },
4899 Stage::StepfunBillingRoute => match key.code {
4900 KeyCode::Esc => {
4901 self.stage = Stage::List;
4902 self.pending_base_url = None;
4903 ViewAction::None
4904 }
4905 KeyCode::Up | KeyCode::Down => {
4906 self.stepfun_billing_route = match self.stepfun_billing_route {
4907 StepfunBillingRoute::PayAsYouGo => StepfunBillingRoute::StepPlan,
4908 StepfunBillingRoute::StepPlan => StepfunBillingRoute::PayAsYouGo,
4909 };
4910 ViewAction::None
4911 }
4912 KeyCode::Char('1') => {
4913 self.stepfun_billing_route = StepfunBillingRoute::PayAsYouGo;
4914 ViewAction::None
4915 }
4916 KeyCode::Char('2') => {
4917 self.stepfun_billing_route = StepfunBillingRoute::StepPlan;
4918 ViewAction::None
4919 }
4920 KeyCode::Enter => {
4921 self.apply_stepfun_billing_route();
4922 ViewAction::None
4923 }
4924 _ => ViewAction::None,
4925 },
4926 Stage::PlanTier => match key.code {
4927 KeyCode::Esc => {
4928 self.stage = Stage::ModelPick;
4929 ViewAction::None
4930 }
4931 KeyCode::Up | KeyCode::Down => {
4932 self.kimi_code_plan_tier = match self.kimi_code_plan_tier {
4933 KimiCodePlanTier::Safe262k => KimiCodePlanTier::OneMillion,
4934 KimiCodePlanTier::OneMillion => KimiCodePlanTier::Safe262k,
4935 };
4936 ViewAction::None
4937 }
4938 KeyCode::Char('1') => {
4939 self.kimi_code_plan_tier = KimiCodePlanTier::Safe262k;
4940 ViewAction::None
4941 }
4942 KeyCode::Char('2') => {
4943 self.kimi_code_plan_tier = KimiCodePlanTier::OneMillion;
4944 ViewAction::None
4945 }
4946 KeyCode::Enter => {
4947 self.apply_plan_tier();
4948 ViewAction::None
4949 }
4950 _ => ViewAction::None,
4951 },
4952 Stage::Confirm => match key.code {
4953 KeyCode::Esc => {
4954 self.stage = if self.selected_kimi_code_k3() {
4955 Stage::PlanTier
4956 } else {
4957 Stage::ModelPick
4958 };
4959 ViewAction::None
4960 }
4961 KeyCode::Enter => self
4962 .build_setup_confirmed_event()
4963 .map(ViewAction::EmitAndClose)
4964 .unwrap_or(ViewAction::None),
4965 _ => ViewAction::None,
4966 },
4967 Stage::CustomForm => match key.code {
4968 KeyCode::Esc => {
4969 self.stage = Stage::List;
4970 ViewAction::None
4971 }
4972 KeyCode::Tab | KeyCode::Down => {
4973 self.advance_custom_field();
4974 ViewAction::None
4975 }
4976 KeyCode::BackTab | KeyCode::Up => {
4977 self.retreat_custom_field();
4978 ViewAction::None
4979 }
4980 KeyCode::Backspace => {
4981 self.custom_form_field_mut().pop();
4982 ViewAction::None
4983 }
4984 KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4985 self.custom_form_field_mut().pop();
4986 ViewAction::None
4987 }
4988 KeyCode::Enter if self.custom_provider_field != CustomProviderField::ApiKeyEnv => {
4989 self.advance_custom_field();
4990 ViewAction::None
4991 }
4992 KeyCode::Enter => self
4993 .build_custom_provider_event()
4994 .map(ViewAction::EmitAndClose)
4995 .unwrap_or(ViewAction::None),
4996 KeyCode::Char(c)
4997 if !key
4998 .modifiers
4999 .contains(crossterm::event::KeyModifiers::CONTROL) =>
5000 {
5001 self.custom_form_field_mut().push(c);
5002 ViewAction::None
5003 }
5004 _ => ViewAction::None,
5005 },
5006 }
5007 }
5008
5009 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
5010 if matches!(mouse.kind, MouseEventKind::Down(_)) {
5011 self.interacted = true;
5012 }
5013 let over_catalog = matches!(self.stage, Stage::List)
5014 && self
5015 .catalog_action_hitbox
5016 .borrow()
5017 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()));
5018 if mouse.kind == MouseEventKind::Moved {
5019 self.catalog_action_hovered = over_catalog;
5020 }
5021 if over_catalog && mouse.kind == MouseEventKind::Down(MouseButton::Left) {
5022 // A catalog view is unfiltered; retaining the search would make
5023 // this visible action appear to do nothing.
5024 if self.search_mode || !self.query.is_empty() {
5025 self.search_mode = false;
5026 self.update_query(String::new());
5027 }
5028 self.toggle_view();
5029 self.catalog_action_hovered = false;
5030 self.last_list_mouse_selected = None;
5031 return ViewAction::None;
5032 }
5033 if matches!(self.stage, Stage::List) {
5034 let over_details = self
5035 .detail_action_hitbox
5036 .borrow()
5037 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()));
5038 if matches!(mouse.kind, MouseEventKind::Moved) {
5039 self.detail_action_hovered = over_details;
5040 }
5041 if over_details && mouse.kind == MouseEventKind::Down(MouseButton::Left) {
5042 return self.open_provider_details();
5043 }
5044 }
5045
5046 match self.stage {
5047 Stage::List => match mouse.kind {
5048 MouseEventKind::ScrollUp => {
5049 self.last_list_mouse_selected = None;
5050 self.move_up();
5051 }
5052 MouseEventKind::ScrollDown => {
5053 self.last_list_mouse_selected = None;
5054 self.move_down();
5055 }
5056 MouseEventKind::Moved => {
5057 self.hovered_list_idx = self.list_hit_at(mouse);
5058 }
5059 MouseEventKind::Down(MouseButton::Left) => {
5060 return self.click_list_row(mouse);
5061 }
5062 _ => {}
5063 },
5064 Stage::ModelPick => match mouse.kind {
5065 MouseEventKind::ScrollUp => {
5066 self.last_model_mouse_selected = None;
5067 self.move_model_selection(-1);
5068 }
5069 MouseEventKind::ScrollDown => {
5070 self.last_model_mouse_selected = None;
5071 self.move_model_selection(1);
5072 }
5073 MouseEventKind::Moved => {
5074 self.hovered_model_idx = self.model_hit_at(mouse);
5075 }
5076 MouseEventKind::Down(MouseButton::Left) => {
5077 return self.click_model_row(mouse);
5078 }
5079 _ => {}
5080 },
5081 Stage::ExternalConsentChoice => match mouse.kind {
5082 MouseEventKind::Moved => {
5083 self.hovered_consent_idx = self.consent_hit_at(mouse);
5084 }
5085 MouseEventKind::Down(MouseButton::Left) => {
5086 self.click_consent_row(mouse);
5087 }
5088 _ => {}
5089 },
5090 Stage::PlanTier
5091 | Stage::StepfunBillingRoute
5092 | Stage::XaiAuthChoice
5093 | Stage::ChatgptAuthChoice => {
5094 let hit = self
5095 .choice_row_hitboxes
5096 .borrow()
5097 .iter()
5098 .find_map(|(rect, key)| {
5099 rect.contains((mouse.column, mouse.row).into())
5100 .then_some(*key)
5101 });
5102 match mouse.kind {
5103 MouseEventKind::Moved => self.hovered_choice = hit,
5104 MouseEventKind::Down(MouseButton::Left) => {
5105 if let Some(key) = hit {
5106 let stage = self.stage;
5107 let activate = self.last_choice_mouse_selected == Some((stage, key));
5108 let _ = self
5109 .handle_key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
5110 self.last_choice_mouse_selected = Some((stage, key));
5111 if activate {
5112 return self
5113 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5114 }
5115 } else {
5116 self.last_choice_mouse_selected = None;
5117 }
5118 }
5119 _ => {}
5120 }
5121 }
5122 Stage::KeyEntry
5123 | Stage::ExternalConsentConfirm
5124 | Stage::ExternalConsentRevokeConfirm
5125 | Stage::Confirm
5126 | Stage::CustomForm => {}
5127 }
5128 ViewAction::None
5129 }
5130
5131 fn render(&self, area: Rect, buf: &mut Buffer) {
5132 self.choice_row_hitboxes.borrow_mut().clear();
5133 self.list_row_hitboxes.borrow_mut().clear();
5134 *self.catalog_action_hitbox.borrow_mut() = None;
5135 *self.detail_action_hitbox.borrow_mut() = None;
5136 // Managing routes needs room for both options and their explanation,
5137 // even with only one configured provider. First-run questions and
5138 // credential/consent flows retain their bounded modal presentation.
5139 if matches!(self.stage, Stage::List) && !self.onboarding_mode {
5140 self.render_list(area, buf);
5141 return;
5142 }
5143 let preferred_height = match self.stage {
5144 Stage::List => (self.rows.len() as u16).saturating_add(2),
5145 Stage::XaiAuthChoice => 12,
5146 Stage::ChatgptAuthChoice => 13,
5147 // Key/OAuth help is intentionally multi-line and wraps at narrow
5148 // widths. One shared height keeps every provider's final guidance
5149 // visible instead of special-casing whichever route clipped last.
5150 Stage::KeyEntry => 14,
5151 Stage::ExternalConsentChoice => 12,
5152 // The disclosure is deliberately long (route, owner, exact path,
5153 // semantics, custody, billing owner, revoke scope) and every line
5154 // wraps; it gets the height it needs so nothing is clipped.
5155 Stage::ExternalConsentConfirm => 20,
5156 Stage::ExternalConsentRevokeConfirm => 12,
5157 Stage::ModelPick => 12,
5158 Stage::PlanTier => 10,
5159 Stage::StepfunBillingRoute => 11,
5160 Stage::Confirm => 10,
5161 // A bundled descriptor adds its credential console, docs and
5162 // guidance under the fields; the guidance sentence wraps.
5163 Stage::CustomForm if self.custom_provider_descriptor.is_some() => 17,
5164 Stage::CustomForm => 12,
5165 };
5166 let popup_area = centered_modal_area(area, 120, preferred_height, 64, 8);
5167
5168 render_modal_surface(area, popup_area, buf);
5169
5170 match self.stage {
5171 Stage::List => self.render_list(popup_area, buf),
5172 Stage::XaiAuthChoice => self.render_xai_auth_choice(popup_area, buf),
5173 Stage::ChatgptAuthChoice => self.render_chatgpt_auth_choice(popup_area, buf),
5174 Stage::KeyEntry => self.render_key_entry(popup_area, buf),
5175 Stage::ExternalConsentChoice => self.render_external_consent_choice(popup_area, buf),
5176 Stage::ExternalConsentConfirm => self.render_external_consent_confirm(popup_area, buf),
5177 Stage::ExternalConsentRevokeConfirm => {
5178 self.render_external_consent_revoke_confirm(popup_area, buf)
5179 }
5180 Stage::ModelPick => self.render_model_pick(popup_area, buf),
5181 Stage::PlanTier => self.render_plan_tier(popup_area, buf),
5182 Stage::StepfunBillingRoute => self.render_stepfun_billing_route(popup_area, buf),
5183 Stage::Confirm => self.render_confirm(popup_area, buf),
5184 Stage::CustomForm => self.render_custom_form(popup_area, buf),
5185 }
5186 }
5187 }
5188
5189 fn non_empty_string(value: &str) -> Option<String> {
5190 let trimmed = value.trim();
5191 (!trimmed.is_empty()).then(|| trimmed.to_string())
5192 }
5193
5194 fn custom_provider_dashboard_rows(
5195 active: ProviderKind,
5196 config: &Config,
5197 runtime_status: Option<&ProviderRuntimeStatus>,
5198 ) -> Vec<ProviderDashboardRow> {
5199 let Some(providers) = config.providers.as_ref() else {
5200 return Vec::new();
5201 };
5202 let mut ids: Vec<_> = providers.custom.keys().cloned().collect();
5203 ids.sort_by_key(|id| id.to_ascii_lowercase());
5204 ids.into_iter()
5205 .map(|id| {
5206 ProviderDashboardRow::from_custom_config_with_runtime_status(
5207 &id,
5208 active,
5209 config,
5210 runtime_status,
5211 )
5212 })
5213 .collect()
5214 }
5215
5216 /// Bundled compatible-host descriptors (#6289) that are not yet written to
5217 /// `[providers.*]`, rendered through the same named-custom-provider row
5218 /// builder a configured host uses. The descriptor JSON carries the endpoint,
5219 /// bootstrap model and credential env var, so the row can report `missing
5220 /// <ENV>` before anything is persisted.
5221 ///
5222 /// Known limitations: these rows are a setup invitation, never a route. They
5223 /// are deliberately not `is_configured`, so they sort with the rest of the
5224 /// unconfigured catalog and stay out of the Configured view; activating one
5225 /// opens the prefilled custom-provider form, and only that form's submit
5226 /// writes `[providers.<id>]`. A descriptor whose id or alias already names a
5227 /// `[providers.*]` entry is dropped here so the configured row is the only
5228 /// one.
5229 fn descriptor_dashboard_rows(
5230 active: ProviderKind,
5231 config: &Config,
5232 runtime_status: Option<&ProviderRuntimeStatus>,
5233 ) -> Vec<ProviderDashboardRow> {
5234 let configured: Vec<&str> = config
5235 .providers
5236 .as_ref()
5237 .map(|providers| providers.custom.keys().map(String::as_str).collect())
5238 .unwrap_or_default();
5239 bundled_provider_descriptors()
5240 .iter()
5241 .filter(|descriptor| !configured.iter().any(|id| descriptor.matches(id)))
5242 .map(|descriptor| descriptor_dashboard_row(descriptor, active, config, runtime_status))
5243 .collect()
5244 }
5245
5246 /// Credential console, docs and guidance a bundled descriptor carries, in the
5247 /// same `Credentials:` / `Docs:` shape the built-in key-entry stage uses. A
5248 /// descriptor without a console falls back to its guidance on the
5249 /// `Credentials:` line, exactly as a built-in provider without one does.
5250 fn descriptor_help_lines(descriptor: &ProviderDescriptor) -> Vec<Line<'static>> {
5251 let muted = Style::default().fg(palette::TEXT_MUTED);
5252 let mut lines = vec![Line::from("")];
5253 match (&descriptor.credential_url, &descriptor.guidance) {
5254 (Some(url), _) => lines.push(Line::from(Span::styled(
5255 format!("Credentials: {url}"),
5256 muted,
5257 ))),
5258 (None, Some(guidance)) => {
5259 lines.push(Line::from(Span::styled(
5260 format!("Credentials: {guidance}"),
5261 muted,
5262 )));
5263 }
5264 (None, None) => {}
5265 }
5266 if let Some(url) = &descriptor.docs_url {
5267 lines.push(Line::from(Span::styled(format!("Docs: {url}"), muted)));
5268 }
5269 if descriptor.credential_url.is_some()
5270 && let Some(guidance) = &descriptor.guidance
5271 {
5272 lines.push(Line::from(Span::styled(guidance.clone(), muted)));
5273 }
5274 lines
5275 }
5276
5277 fn descriptor_dashboard_row(
5278 descriptor: &ProviderDescriptor,
5279 active: ProviderKind,
5280 config: &Config,
5281 runtime_status: Option<&ProviderRuntimeStatus>,
5282 ) -> ProviderDashboardRow {
5283 // Project the descriptor into the `[providers.<id>]` shape the user would
5284 // write, so endpoint, model and credential reporting all come from the one
5285 // existing row builder instead of a second pipeline. `scoped` is a local
5286 // clone; nothing here touches the loaded or on-disk config.
5287 let mut scoped = config.clone();
5288 scoped
5289 .providers
5290 .get_or_insert_with(Default::default)
5291 .custom
5292 .insert(
5293 descriptor.id.clone(),
5294 crate::config::ProviderConfig {
5295 kind: Some("openai-compatible".to_string()),
5296 base_url: Some(descriptor.base_url.clone()),
5297 model: Some(descriptor.default_model.clone()),
5298 api_key_env: Some(descriptor.api_key_env.clone()),
5299 ..Default::default()
5300 },
5301 );
5302 let mut row = ProviderDashboardRow::from_custom_config_with_runtime_status(
5303 &descriptor.id,
5304 active,
5305 &scoped,
5306 runtime_status,
5307 );
5308 // The host's own name, not `<id> (custom)`: this is a catalog row.
5309 row.display_name = descriptor.label.clone();
5310 // Nothing is persisted yet, so this is an offer, not a configured host.
5311 row.is_configured = false;
5312 row.identity = None;
5313 row.route_identity = None;
5314 row.is_active = false;
5315 row
5316 }
5317
5318 #[cfg(test)]
5319 mod tests {
5320 use super::*;
5321 use crate::config::has_api_key_for;
5322 use crate::test_support::EnvVarGuard;
5323 use crossterm::event::{KeyEvent, KeyModifiers};
5324
5325 // Environment-mutating tests in this module hold the process-wide
5326 // `lock_test_env()` (via `crate::test_support`), the same barrier every
5327 // other module's env tests use. A module-private mutex cannot serialize
5328 // against the rest of the suite, so sibling tests raced on shared
5329 // provider env vars (EXAMPLE_API_KEY, OPENROUTER_API_KEY, ...) and a panic
5330 // while holding it cascaded PoisonError failures into unrelated tests.
5331
5332 #[test]
5333 fn provider_inspector_keeps_exact_diagnostics_in_clickable_keyboard_pager() {
5334 let _env = crate::test_support::lock_test_env();
5335 let config = Config::default();
5336 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5337 let message = "Recovery fixture · exact diagnostic source ".repeat(12);
5338 let selected = picker.selected_idx;
5339 picker.rows[selected].messages = vec![
5340 message.clone(),
5341 "second diagnostic".into(),
5342 "third diagnostic".into(),
5343 ];
5344 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
5345 let overview = render_text(&picker, width, height);
5346 assert!(!overview.contains(&message));
5347 let hit = picker
5348 .detail_action_hitbox
5349 .borrow()
5350 .expect("visible details action");
5351 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) =
5352 picker.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT))
5353 else {
5354 panic!("the shared details shortcut must open the existing pager")
5355 };
5356 assert!(content.contains(&message));
5357 assert!(content.contains("third diagnostic"));
5358 assert!(content.contains("Protocol:"));
5359 let ViewAction::Emit(ViewEvent::OpenTextPager {
5360 title: clicked_title,
5361 content: clicked_content,
5362 }) = picker.handle_mouse(MouseEvent {
5363 kind: MouseEventKind::Down(MouseButton::Left),
5364 column: hit.x,
5365 row: hit.y,
5366 modifiers: KeyModifiers::NONE,
5367 })
5368 else {
5369 panic!("click must open the same pager")
5370 };
5371 assert_eq!(clicked_title, title);
5372 assert_eq!(clicked_content, content);
5373 assert_eq!(picker.selected_idx, selected);
5374 }
5375 }
5376
5377 #[test]
5378 fn workbench_setup_choice_clicks_share_keyboard_confirmation_paths() {
5379 let _env = crate::test_support::lock_test_env();
5380 for stage in [
5381 Stage::PlanTier,
5382 Stage::StepfunBillingRoute,
5383 Stage::XaiAuthChoice,
5384 Stage::ChatgptAuthChoice,
5385 ] {
5386 for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
5387 let config = Config::default();
5388 let mut pointer = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5389 let mut keyboard = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5390 pointer.stage = stage;
5391 keyboard.stage = stage;
5392 let area = Rect::new(0, 0, width, height);
5393 let mut buf = Buffer::empty(area);
5394 pointer.render(area, &mut buf);
5395 let hit = pointer
5396 .choice_row_hitboxes
5397 .borrow()
5398 .iter()
5399 .find(|(_, key)| {
5400 *key == if stage == Stage::ChatgptAuthChoice {
5401 '1'
5402 } else {
5403 '2'
5404 }
5405 })
5406 .expect("setup choice visible")
5407 .0;
5408 let event = MouseEvent {
5409 kind: MouseEventKind::Down(MouseButton::Left),
5410 column: hit.x,
5411 row: hit.y,
5412 modifiers: KeyModifiers::NONE,
5413 };
5414 assert!(matches!(pointer.handle_mouse(event), ViewAction::None));
5415 assert_eq!(pointer.stage, stage, "first click only selects");
5416 keyboard.handle_key(key(KeyCode::Char(if stage == Stage::ChatgptAuthChoice {
5417 '1'
5418 } else {
5419 '2'
5420 })));
5421 let actual = pointer.handle_mouse(event);
5422 let expected = keyboard.handle_key(key(KeyCode::Enter));
5423 assert_eq!(format!("{actual:?}"), format!("{expected:?}"));
5424 assert_eq!(pointer.stage, keyboard.stage);
5425 assert_eq!(pointer.pending_base_url, keyboard.pending_base_url);
5426 assert_eq!(
5427 pointer.selected_context_window,
5428 keyboard.selected_context_window
5429 );
5430 }
5431 }
5432 }
5433
5434 fn key(code: KeyCode) -> KeyEvent {
5435 KeyEvent::new(code, KeyModifiers::NONE)
5436 }
5437
5438 fn ctrl(code: KeyCode) -> KeyEvent {
5439 KeyEvent::new(code, KeyModifiers::CONTROL)
5440 }
5441
5442 fn move_to_provider(picker: &mut ProviderPickerView, provider: ProviderKind) {
5443 // The target may be hidden by the default configured-only view
5444 // (#3830); switch to the full catalog so navigation can still reach
5445 // it, matching what a user pressing `A` would do.
5446 if let Some(idx) = picker.rows.iter().position(|row| row.provider == provider)
5447 && !picker.row_visible(idx)
5448 {
5449 picker.toggle_view();
5450 }
5451 let max_steps = picker.rows.len();
5452 for _ in 0..max_steps {
5453 if picker.selected_provider() == provider {
5454 return;
5455 }
5456 picker.handle_key(key(KeyCode::Down));
5457 }
5458 panic!("provider {provider:?} not found in picker");
5459 }
5460
5461 fn render_text(picker: &ProviderPickerView, width: u16, height: u16) -> String {
5462 let area = Rect::new(0, 0, width, height);
5463 let mut buf = Buffer::empty(area);
5464 picker.render(area, &mut buf);
5465 (0..height)
5466 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
5467 .collect::<Vec<_>>()
5468 .join("\n")
5469 }
5470
5471 #[test]
5472 fn failed_live_catalog_refresh_names_the_working_fallback() {
5473 assert_eq!(
5474 catalog_freshness_title_suffix_for(ModelsDevFreshness::Failed),
5475 " · refresh failed; catalog available"
5476 );
5477 }
5478
5479 #[test]
5480 fn provider_picker_semantically_truncates_dense_rows_at_narrow_width() {
5481 let config = Config::default();
5482 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5483 picker.toggle_view();
5484
5485 // The invariant is that nothing overflows the frame at any width.
5486 // This used to also require an ellipsis at 64 columns, which only
5487 // held while every row carried a ten-field pipe dump; the rows are
5488 // short now and simply fit, which is the improvement rather than a
5489 // regression. Narrow widths still exercise the truncation path.
5490 for width in [40u16, 64, 100] {
5491 let text = render_text(&picker, width, 16);
5492 for (idx, line) in text.lines().enumerate() {
5493 assert!(
5494 crate::tui::ui_text::text_display_width(line) <= usize::from(width),
5495 "line {idx} overflows at {width}: {line:?}"
5496 );
5497 }
5498 }
5499 }
5500
5501 #[test]
5502 fn type_ahead_jumps_to_provider_by_first_letter() {
5503 let config = Config::default();
5504 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5505 // Z.ai isn't configured, so it's hidden by the default view (#3830);
5506 // browse the full catalog like a user pressing `A` would.
5507 picker.toggle_view();
5508 // Search for "zai" — unique enough to match only Z.ai.
5509 for c in "zai".chars() {
5510 picker.handle_key(key(KeyCode::Char(c)));
5511 }
5512 assert_eq!(picker.query, "zai");
5513 let filtered = picker.filtered_rows();
5514 assert!(!filtered.is_empty(), "search for 'zai' must match Z.ai");
5515 assert!(
5516 filtered
5517 .iter()
5518 .any(|(_, row)| row.provider == ProviderKind::Zai),
5519 "Z.ai must be in filtered results: {:?}",
5520 filtered
5521 .iter()
5522 .map(|(_, r)| &r.display_name)
5523 .collect::<Vec<_>>()
5524 );
5525 assert_eq!(picker.selected_provider(), ProviderKind::Zai);
5526 }
5527
5528 #[test]
5529 fn mouse_scroll_moves_selection_in_list_stage() {
5530 let config = Config::default();
5531 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5532 // Scroll across the full catalog (#3830), not just the configured
5533 // subset, which would only contain the active provider here.
5534 picker.toggle_view();
5535 let before = picker.selected_idx;
5536 picker.handle_mouse(MouseEvent {
5537 kind: MouseEventKind::ScrollDown,
5538 column: 0,
5539 row: 0,
5540 modifiers: KeyModifiers::NONE,
5541 });
5542 assert_ne!(
5543 picker.selected_idx, before,
5544 "scroll down should advance the selection"
5545 );
5546 }
5547
5548 #[test]
5549 fn picker_lists_all_providers() {
5550 let config = Config::default();
5551 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5552 let names: Vec<_> = picker
5553 .rows
5554 .iter()
5555 .map(|row| row.display_name.as_str())
5556 .collect();
5557
5558 // Catalog surface: one identity per vendor (not dual-wire / plan
5559 // kinds). Setup templates are retired (#6289); the compatible hosts
5560 // that replaced them are data rows from
5561 // `provider_descriptors.json`, one each, on top of the catalog.
5562 assert_eq!(
5563 names.len(),
5564 ProviderKind::all().len() + bundled_provider_descriptors().len()
5565 );
5566 assert!(names.contains(&"DeepSeek"));
5567 assert!(names.contains(&"Alibaba Cloud Model Studio"));
5568 // Dialect is wire config — no second MiniMax / Model Studio rows.
5569 assert_eq!(
5570 names
5571 .iter()
5572 .filter(|name| name.contains("Alibaba Cloud Model Studio"))
5573 .count(),
5574 1
5575 );
5576 assert_eq!(names.iter().filter(|name| **name == "MiniMax").count(), 1);
5577 assert_eq!(names.iter().filter(|name| **name == "DeepSeek").count(), 1);
5578
5579 // Configured providers lead, then the rest of the catalog in neutral
5580 // case-insensitive alphabetical order by display name (#3076), not
5581 // `ProviderKind::all()` order. Founder ruling: "the ones you have
5582 // configured at the top then everything else below".
5583 let configured_count = picker.rows.iter().filter(|row| row.is_configured).count();
5584 let (configured, rest) = names.split_at(configured_count);
5585 for group in [configured, rest] {
5586 let mut expected = group.to_vec();
5587 expected.sort_by_key(|name| name.to_ascii_lowercase());
5588 assert_eq!(
5589 group, expected,
5590 "each group is case-insensitive alphabetical within itself"
5591 );
5592 }
5593 assert!(
5594 picker
5595 .rows
5596 .iter()
5597 .take(configured_count)
5598 .all(|row| row.is_configured),
5599 "configured providers lead the list"
5600 );
5601 }
5602
5603 /// A named custom provider appears exactly once, as a configured row.
5604 #[test]
5605 fn a_configured_custom_provider_appears_once() {
5606 let mut config = Config::default();
5607 config
5608 .providers
5609 .get_or_insert_with(Default::default)
5610 .custom
5611 .insert(
5612 "baseten".to_string(),
5613 crate::config::ProviderConfig {
5614 kind: Some("openai-compatible".to_string()),
5615 base_url: Some("https://inference.baseten.co/v1".to_string()),
5616 model: Some("deepseek-ai/DeepSeek-V3.1".to_string()),
5617 api_key_env: Some("BASETEN_API_KEY".to_string()),
5618 ..Default::default()
5619 },
5620 );
5621 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5622 let baseten: Vec<_> = picker
5623 .rows
5624 .iter()
5625 .filter(|row| row.provider_id == "baseten")
5626 .collect();
5627 assert_eq!(baseten.len(), 1, "one Baseten row, the configured one");
5628 assert!(baseten[0].is_configured);
5629 }
5630
5631 /// #6289 moved the compatible hosts into
5632 /// `crates/config/assets/provider_descriptors.json` and wired the file to
5633 /// nothing, so SenseNova, Baseten, Groq, Cerebras, DashScope and Command
5634 /// Code silently lost their `/provider` rows (and AICraft never got one).
5635 /// Every bundled descriptor is a findable row again — exactly one each,
5636 /// carrying the host's own name, endpoint, bootstrap model and the
5637 /// credential env var it is still missing.
5638 #[test]
5639 fn every_bundled_descriptor_is_a_picker_row_exactly_once() {
5640 let _env = crate::test_support::lock_test_env();
5641 let _keys: Vec<_> = bundled_provider_descriptors()
5642 .iter()
5643 .map(|descriptor| crate::test_support::EnvVarGuard::remove(&descriptor.api_key_env))
5644 .collect();
5645 let config = Config::default();
5646 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5647 assert!(
5648 !bundled_provider_descriptors().is_empty(),
5649 "the bundled descriptor file must not be empty"
5650 );
5651 for descriptor in bundled_provider_descriptors() {
5652 let rows: Vec<_> = picker
5653 .rows
5654 .iter()
5655 .filter(|row| row.provider_id == descriptor.id)
5656 .collect();
5657 assert_eq!(rows.len(), 1, "one row for {}", descriptor.id);
5658 let row = rows[0];
5659 assert_eq!(row.provider, ProviderKind::Custom, "{}", descriptor.id);
5660 assert_eq!(row.display_name, descriptor.label);
5661 assert_eq!(row.base_url, descriptor.base_url, "{}", descriptor.id);
5662 assert_eq!(
5663 row.default_route.logical_model, descriptor.default_model,
5664 "{}",
5665 descriptor.id
5666 );
5667 assert!(
5668 !row.has_key,
5669 "{} has no credential with its env var unset",
5670 descriptor.id
5671 );
5672 assert!(
5673 !row.is_configured,
5674 "{} is an offer to set up, not a configured host",
5675 descriptor.id
5676 );
5677 assert!(
5678 row.messages
5679 .iter()
5680 .any(|message| message.contains(&descriptor.api_key_env)),
5681 "{} must name the credential it is missing: {:?}",
5682 descriptor.id,
5683 row.messages
5684 );
5685 }
5686 }
5687
5688 /// The bundled offer never doubles a host the user already wrote down:
5689 /// `[providers.groq]` keeps its own configured row and nothing else.
5690 #[test]
5691 fn a_configured_descriptor_id_does_not_duplicate_its_row() {
5692 let descriptor = provider_descriptor("groq").expect("groq descriptor");
5693 let mut config = Config::default();
5694 config
5695 .providers
5696 .get_or_insert_with(Default::default)
5697 .custom
5698 .insert(
5699 descriptor.id.clone(),
5700 crate::config::ProviderConfig {
5701 kind: Some("openai-compatible".to_string()),
5702 base_url: Some(descriptor.base_url.clone()),
5703 model: Some(descriptor.default_model.clone()),
5704 api_key_env: Some(descriptor.api_key_env.clone()),
5705 ..Default::default()
5706 },
5707 );
5708 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5709 let rows: Vec<_> = picker
5710 .rows
5711 .iter()
5712 .filter(|row| row.provider_id == descriptor.id)
5713 .collect();
5714 assert_eq!(rows.len(), 1, "the configured row wins");
5715 assert!(rows[0].is_configured);
5716 assert_eq!(rows[0].display_name, "groq (custom)");
5717 }
5718
5719 /// #6616: a bundled descriptor's credential console, docs link and
5720 /// guidance reach the user on the form that sets it up, in the same
5721 /// `Credentials:` / `Docs:` shape the built-in key-entry stage uses.
5722 /// A hand-entered custom host carries none of them.
5723 #[test]
5724 fn descriptor_form_shows_the_hosts_credential_docs_and_guidance() {
5725 let _env = crate::test_support::lock_test_env();
5726 let descriptor = provider_descriptor("aicraft").expect("aicraft descriptor");
5727 let _key = crate::test_support::EnvVarGuard::remove(&descriptor.api_key_env);
5728 let config = Config::default();
5729 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5730 picker.view = ProviderListView::Catalog;
5731 picker.selected_idx = picker
5732 .rows
5733 .iter()
5734 .position(|row| row.provider_id == descriptor.id)
5735 .expect("an AICraft row");
5736 picker.handle_key(key(KeyCode::Enter));
5737 assert_eq!(picker.stage, Stage::CustomForm);
5738
5739 let rendered = render_text(&picker, 120, 24);
5740 assert!(
5741 rendered.contains("Credentials: https://aicraftapi.com/dashboard.html"),
5742 "{rendered}"
5743 );
5744 assert!(
5745 rendered.contains("Docs: https://aicraftapi.com/docs.html#codewhale"),
5746 "{rendered}"
5747 );
5748 assert!(rendered.contains("Store AICRAFT_API_KEY"), "{rendered}");
5749
5750 picker.enter_custom_form();
5751 let rendered = render_text(&picker, 120, 24);
5752 assert!(!rendered.contains("Credentials:"), "{rendered}");
5753 assert!(!rendered.contains("aicraftapi.com"), "{rendered}");
5754 }
5755
5756 /// Setting a descriptor row up goes through the named-custom-provider
5757 /// submit a hand-entered host uses, so `[providers.<id>]` lands with the
5758 /// descriptor's endpoint and bootstrap model. The only field left open is
5759 /// which env var holds the key, and that is where the cursor starts.
5760 #[test]
5761 fn activating_a_descriptor_row_submits_it_as_a_named_custom_provider() {
5762 let _env = crate::test_support::lock_test_env();
5763 let descriptor = provider_descriptor("groq").expect("groq descriptor");
5764 let _key = crate::test_support::EnvVarGuard::remove(&descriptor.api_key_env);
5765 let config = Config::default();
5766 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5767 picker.view = ProviderListView::Catalog;
5768 picker.selected_idx = picker
5769 .rows
5770 .iter()
5771 .position(|row| row.provider_id == descriptor.id)
5772 .expect("a Groq row");
5773
5774 assert!(matches!(
5775 picker.handle_key(key(KeyCode::Enter)),
5776 ViewAction::None
5777 ));
5778 assert_eq!(picker.stage, Stage::CustomForm);
5779 assert_eq!(picker.custom_provider_field, CustomProviderField::ApiKeyEnv);
5780 assert_eq!(picker.custom_provider_id, descriptor.id);
5781 assert_eq!(picker.custom_provider_base_url, descriptor.base_url);
5782 assert_eq!(picker.custom_provider_model, descriptor.default_model);
5783
5784 match picker.handle_key(key(KeyCode::Enter)) {
5785 ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
5786 provider_id,
5787 base_url,
5788 model,
5789 api_key_env,
5790 }) => {
5791 assert_eq!(provider_id, descriptor.id);
5792 assert_eq!(base_url, descriptor.base_url);
5793 assert_eq!(model.as_deref(), Some(descriptor.default_model.as_str()));
5794 assert_eq!(
5795 api_key_env.as_deref(),
5796 Some(descriptor.api_key_env.as_str())
5797 );
5798 }
5799 other => panic!("expected custom provider submit event, got {other:?}"),
5800 }
5801 }
5802
5803 /// First-run catalog: the blank Custom slot fails route admission by
5804 /// design, but it is the way into the endpoint form, not a legacy route.
5805 #[test]
5806 fn blank_custom_slot_reads_as_setup_not_legacy() {
5807 let config = Config::default();
5808 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5809 let row = picker
5810 .rows
5811 .iter()
5812 .find(|row| row.provider_id == ProviderKind::Custom.as_str())
5813 .expect("blank custom slot");
5814 assert_eq!(row.display_name, "Custom (OpenAI-compatible)");
5815 assert!(row.is_custom_placeholder());
5816 assert_eq!(
5817 row.list_row_hint(ProviderListView::Catalog),
5818 "needs endpoint"
5819 );
5820 }
5821
5822 #[test]
5823 fn default_view_shows_only_configured_providers() {
5824 // #3830: with nothing but the active provider set up, the default
5825 // list view excludes the unconfigured catalog noise — even though
5826 // `rows` (the underlying data) still has every provider, per
5827 // `picker_lists_all_providers` above. Doesn't assert an exact count:
5828 // `OpenaiCodex` reads a real OAuth file from disk in
5829 // `has_api_key_for`, so it's legitimately "configured" on a machine
5830 // with a prior Codex login and must not make this test host-dependent.
5831 let config = Config::default();
5832 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5833
5834 assert_eq!(picker.view, ProviderListView::Configured);
5835 let visible: Vec<ProviderKind> = picker
5836 .filtered_rows()
5837 .iter()
5838 .map(|(_, row)| row.provider)
5839 .collect();
5840 assert!(visible.contains(&ProviderKind::Deepseek), "{visible:?}");
5841 assert!(
5842 !visible.contains(&ProviderKind::Custom),
5843 "the unused custom-provider placeholder slot isn't \"configured\": {visible:?}"
5844 );
5845 for unconfigured in [
5846 ProviderKind::Zai,
5847 ProviderKind::Openrouter,
5848 ProviderKind::Novita,
5849 ProviderKind::Ollama,
5850 ] {
5851 assert!(
5852 !visible.contains(&unconfigured),
5853 "{unconfigured:?} has no credentials and isn't active: {visible:?}"
5854 );
5855 }
5856 assert!(
5857 picker.rows.len() > visible.len(),
5858 "underlying data keeps every provider"
5859 );
5860 }
5861
5862 #[test]
5863 fn explicit_provider_config_marks_provider_configured_without_active_or_key() {
5864 // #3830: a non-default `[providers.<name>]` entry (here just a base
5865 // URL override, no key) counts as "configured" even though the
5866 // provider is neither active nor has working credentials.
5867 let config = Config {
5868 providers: Some(crate::config::ProvidersConfig {
5869 openrouter: crate::config::ProviderConfig {
5870 base_url: Some("https://custom.openrouter.example/v1".to_string()),
5871 ..Default::default()
5872 },
5873 ..Default::default()
5874 }),
5875 ..Config::default()
5876 };
5877 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5878 let row = picker
5879 .rows
5880 .iter()
5881 .find(|row| row.provider == ProviderKind::Openrouter)
5882 .expect("openrouter row");
5883 assert!(row.is_configured);
5884 assert!(!row.has_key, "explicit config doesn't imply a working key");
5885 }
5886
5887 #[test]
5888 fn empty_provider_headers_do_not_mark_provider_configured() {
5889 let _env = crate::test_support::lock_test_env();
5890 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
5891 let config = Config {
5892 providers: Some(crate::config::ProvidersConfig {
5893 anthropic: crate::config::ProviderConfig {
5894 http_headers: Some(std::collections::HashMap::new()),
5895 ..Default::default()
5896 },
5897 ..Default::default()
5898 }),
5899 ..Config::default()
5900 };
5901 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5902 let anthropic = picker
5903 .rows
5904 .iter()
5905 .find(|row| row.provider == ProviderKind::Anthropic)
5906 .expect("anthropic row");
5907
5908 assert!(
5909 !anthropic.is_configured,
5910 "an empty deserialized header table is default state, not setup"
5911 );
5912 }
5913
5914 #[test]
5915 fn non_empty_provider_headers_mark_provider_configured() {
5916 let config = Config {
5917 providers: Some(crate::config::ProvidersConfig {
5918 anthropic: crate::config::ProviderConfig {
5919 http_headers: Some(std::collections::HashMap::from([(
5920 "X-Route".to_string(),
5921 "custom".to_string(),
5922 )])),
5923 ..Default::default()
5924 },
5925 ..Default::default()
5926 }),
5927 ..Config::default()
5928 };
5929 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5930 let anthropic = picker
5931 .rows
5932 .iter()
5933 .find(|row| row.provider == ProviderKind::Anthropic)
5934 .expect("anthropic row");
5935
5936 assert!(
5937 anthropic.is_configured,
5938 "a user-authored header is meaningful explicit provider setup"
5939 );
5940 }
5941
5942 #[test]
5943 fn blank_provider_header_entries_do_not_mark_provider_configured() {
5944 let _env = crate::test_support::lock_test_env();
5945 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
5946 let config = Config {
5947 providers: Some(crate::config::ProvidersConfig {
5948 anthropic: crate::config::ProviderConfig {
5949 http_headers: Some(std::collections::HashMap::from([
5950 (" ".to_string(), "value".to_string()),
5951 ("X-Blank".to_string(), " ".to_string()),
5952 ])),
5953 ..Default::default()
5954 },
5955 ..Default::default()
5956 }),
5957 ..Config::default()
5958 };
5959 assert!(!crate::config::provider_is_configured_for_active(
5960 &config,
5961 &(config).test_identity_for_kind(ProviderKind::Anthropic),
5962 &(config).test_identity_for_kind(ProviderKind::Deepseek),
5963 ));
5964 }
5965
5966 #[test]
5967 fn self_hosted_provider_not_auto_configured_without_explicit_setup() {
5968 // #3830: `has_api_key_for` always reports `true` for self-hosted
5969 // providers (no auth required to route to them) — that must not, on
5970 // its own, make Ollama/Sglang/Vllm show up in the default
5971 // configured-only view for every user regardless of setup.
5972 let config = Config::default();
5973 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
5974 let ollama = picker
5975 .rows
5976 .iter()
5977 .find(|row| row.provider == ProviderKind::Ollama)
5978 .expect("ollama row");
5979 assert!(
5980 ollama.has_key,
5981 "self-hosted providers report has_key unconditionally"
5982 );
5983 assert!(
5984 !ollama.is_configured,
5985 "but that alone must not mark them configured"
5986 );
5987
5988 // Active self-hosted provider still counts as configured.
5989 let active_config = Config {
5990 provider: Some("ollama".into()),
5991 ..Config::default()
5992 };
5993 let active_picker = ProviderPickerView::new(ProviderKind::Ollama, &active_config);
5994 let active_ollama = active_picker
5995 .rows
5996 .iter()
5997 .find(|row| row.provider == ProviderKind::Ollama)
5998 .expect("ollama row");
5999 assert!(active_ollama.is_configured);
6000 }
6001
6002 #[test]
6003 fn explicit_provider_search_accepts_shortcut_letters_and_escape_restores_actions() {
6004 let _env = crate::test_support::lock_test_env();
6005 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &Config::default());
6006 let view = picker.view;
6007 picker.handle_key(key(KeyCode::Char('/')));
6008 for ch in "Anthropic".chars() {
6009 assert!(matches!(
6010 picker.handle_key(KeyEvent::new(
6011 KeyCode::Char(ch),
6012 if ch.is_uppercase() {
6013 KeyModifiers::SHIFT
6014 } else {
6015 KeyModifiers::NONE
6016 }
6017 )),
6018 ViewAction::None
6019 ));
6020 }
6021 assert_eq!(picker.query, "Anthropic");
6022 assert_eq!(picker.stage, Stage::List);
6023 assert_eq!(picker.view, view);
6024 // Modified commands retain their own meaning in explicit search.
6025 let _ = picker.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
6026 assert_eq!(picker.query, "Anthropic");
6027 assert!(matches!(
6028 picker.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
6029 ViewAction::Emit(ViewEvent::OpenTextPager { .. })
6030 ));
6031 assert!(
6032 picker
6033 .filtered_rows()
6034 .iter()
6035 .any(|(_, row)| row.provider == ProviderKind::Anthropic)
6036 );
6037 picker.handle_key(key(KeyCode::Esc));
6038 picker.handle_key(key(KeyCode::Char('/')));
6039 assert!(picker.handle_paste("Anthropic"));
6040 assert_eq!(picker.query, "Anthropic");
6041 picker.handle_key(key(KeyCode::Esc));
6042 assert!(!picker.search_mode);
6043 assert!(picker.query.is_empty());
6044 picker.handle_key(key(KeyCode::Char('a')));
6045 assert_ne!(picker.view, view);
6046 picker.handle_key(key(KeyCode::Char('/')));
6047 picker.handle_key(key(KeyCode::Esc));
6048 assert!(!picker.search_mode);
6049 picker.handle_key(key(KeyCode::Char('/')));
6050 for ch in "anthropic".chars() {
6051 picker.handle_key(key(KeyCode::Char(ch)));
6052 }
6053 assert_eq!(picker.selected_provider(), ProviderKind::Anthropic);
6054 let action = picker.handle_key(key(KeyCode::Enter));
6055 assert!(
6056 matches!(action, ViewAction::EmitAndClose(_)) || picker.stage != Stage::List,
6057 "Enter on a search result must apply or open its setup"
6058 );
6059 }
6060
6061 #[test]
6062 fn provider_catalog_header_click_matches_keyboard_and_clears_stale_hits() {
6063 let _env = crate::test_support::lock_test_env();
6064 let config = Config::default();
6065 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
6066 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6067 let mut keyboard = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6068 render_text(&picker, width, height);
6069 let hit = picker
6070 .catalog_action_hitbox
6071 .borrow()
6072 .expect("catalog action");
6073 assert!(hit.right() <= width && hit.y < height);
6074 assert!(matches!(
6075 picker.handle_mouse(MouseEvent {
6076 kind: MouseEventKind::Down(MouseButton::Left),
6077 column: hit.x,
6078 row: hit.y,
6079 modifiers: KeyModifiers::NONE,
6080 }),
6081 ViewAction::None
6082 ));
6083 keyboard.handle_key(key(KeyCode::Char('a')));
6084 assert_eq!(picker.view, keyboard.view);
6085 assert_eq!(picker.selected_idx, keyboard.selected_idx);
6086 // No matches must not retain clickable provider rows from the
6087 // previous frame; the catalog control remains a separate action.
6088 picker.update_query("definitely-no-provider-matches-this".into());
6089 render_text(&picker, width, height);
6090 assert!(picker.list_row_hitboxes.borrow().is_empty());
6091 render_text(&picker, 0, 0);
6092 assert!(picker.catalog_action_hitbox.borrow().is_none());
6093 }
6094 }
6095
6096 #[test]
6097 fn toggle_view_reveals_full_catalog_and_back() {
6098 let config = Config::default();
6099 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6100 let configured_count = picker.filtered_rows().len();
6101 assert_eq!(picker.view, ProviderListView::Configured);
6102
6103 let action = picker.handle_key(key(KeyCode::Char('a')));
6104 assert!(matches!(action, ViewAction::None));
6105 assert_eq!(picker.view, ProviderListView::Catalog);
6106 assert_eq!(picker.filtered_rows().len(), picker.rows.len());
6107 assert!(picker.filtered_rows().len() > configured_count);
6108
6109 picker.handle_key(key(KeyCode::Char('A')));
6110 assert_eq!(picker.view, ProviderListView::Configured);
6111 assert_eq!(picker.filtered_rows().len(), configured_count);
6112 }
6113
6114 #[test]
6115 fn key_entry_hint_uses_metadata_env_vars() {
6116 assert_eq!(
6117 ProviderPickerView::env_var_for(ProviderKind::NvidiaNim),
6118 "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY"
6119 );
6120 }
6121
6122 #[test]
6123 fn key_entry_hint_includes_provider_credential_url() {
6124 let config = Config::default();
6125 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6126 move_to_provider(&mut picker, ProviderKind::NvidiaNim);
6127 picker.handle_key(key(KeyCode::Enter));
6128
6129 let rendered = render_text(&picker, 120, 20);
6130
6131 assert!(rendered.contains("NVIDIA_API_KEY / NVIDIA_NIM_API_KEY"));
6132 assert!(rendered.contains("https://build.nvidia.com/settings/api-keys"));
6133 }
6134
6135 #[test]
6136 fn zai_key_entry_wraps_long_environment_guidance_without_hiding_credentials_url() {
6137 let config = Config::default();
6138 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6139 move_to_provider(&mut picker, ProviderKind::Zai);
6140 picker.handle_key(key(KeyCode::Enter));
6141
6142 // Reproduce the width from the dogfood screenshot: the old renderer
6143 // allocated one row per logical line, so the long env-var sentence
6144 // clipped and displaced the credentials URL.
6145 let rendered = render_text(&picker, 100, 20);
6146
6147 for name in [
6148 "ZAI_API_KEY",
6149 "Z_AI_API_KEY",
6150 "ZHIPU_API_KEY",
6151 "GLM_API_KEY",
6152 ] {
6153 assert!(rendered.contains(name), "missing {name}:\n{rendered}");
6154 }
6155 assert!(rendered.contains("re-open /provider."), "{rendered}");
6156 assert!(
6157 rendered.contains("Credentials: https://z.ai/model-api"),
6158 "{rendered}"
6159 );
6160 }
6161
6162 #[test]
6163 fn kimi_key_entry_uses_the_direct_api_key_console_without_oauth_copy() {
6164 let config = Config::default();
6165 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6166 move_to_provider(&mut picker, ProviderKind::Moonshot);
6167 picker.handle_key(key(KeyCode::Enter));
6168
6169 let rendered = render_text(&picker, 120, 20);
6170
6171 assert!(rendered.contains("https://platform.kimi.ai/console/api-keys"));
6172 assert!(rendered.contains("paste key here"));
6173 assert!(!rendered.contains("OAuth"));
6174 assert!(!rendered.contains("device login"));
6175 }
6176
6177 #[test]
6178 fn kimi_code_plan_key_entry_uses_membership_route_guidance() {
6179 let config = Config {
6180 provider: Some("moonshot".to_string()),
6181 providers: Some(crate::config::ProvidersConfig {
6182 moonshot: crate::config::ProviderConfig {
6183 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
6184 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
6185 ..Default::default()
6186 },
6187 ..Default::default()
6188 }),
6189 ..Default::default()
6190 };
6191 let mut picker = ProviderPickerView::new(ProviderKind::Moonshot, &config);
6192 assert_eq!(picker.selected_provider(), ProviderKind::Moonshot);
6193 picker.handle_key(key(KeyCode::Enter));
6194
6195 let rendered = render_text(&picker, 120, 24);
6196
6197 assert!(rendered.contains("https://www.kimi.com/code/console"));
6198 assert!(rendered.contains("api.kimi.com/coding/v1"));
6199 assert!(rendered.contains("does not import Kimi CLI credentials"));
6200 assert!(!rendered.contains("https://platform.kimi.ai/console/api-keys"));
6201 assert!(!rendered.contains("OAuth"));
6202 }
6203
6204 #[test]
6205 fn recovery_picker_keeps_active_route_and_esc_makes_no_change() {
6206 let config = Config {
6207 provider: Some("moonshot".to_string()),
6208 providers: Some(crate::config::ProvidersConfig {
6209 moonshot: crate::config::ProviderConfig {
6210 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
6211 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
6212 ..Default::default()
6213 },
6214 ..Default::default()
6215 }),
6216 ..Default::default()
6217 };
6218 let mut picker = ProviderPickerView::new(ProviderKind::Moonshot, &config);
6219
6220 assert_eq!(picker.stage, Stage::List);
6221 assert_eq!(picker.selected_provider(), ProviderKind::Moonshot);
6222 assert!(matches!(
6223 picker.handle_key(key(KeyCode::Esc)),
6224 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
6225 ));
6226 assert_eq!(config.provider.as_deref(), Some("moonshot"));
6227 assert_eq!(
6228 config
6229 .provider_config_for(&config.test_identity_for_kind(ProviderKind::Moonshot))
6230 .and_then(|entry| entry.base_url.as_deref()),
6231 Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL)
6232 );
6233 }
6234
6235 #[test]
6236 fn recovery_model_pick_restores_exact_kimi_code_k3_without_catalog_leakage() {
6237 let mut config = Config {
6238 provider: Some("moonshot".to_string()),
6239 providers: Some(crate::config::ProvidersConfig {
6240 moonshot: crate::config::ProviderConfig {
6241 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
6242 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
6243 ..Default::default()
6244 },
6245 ..Default::default()
6246 }),
6247 ..Default::default()
6248 };
6249 let picker = ProviderPickerView::new_for_model_pick_after_validation(
6250 ProviderKind::Moonshot,
6251 &(config).test_identity_for_kind(ProviderKind::Moonshot),
6252 &config,
6253 None,
6254 "validated-key".to_string(),
6255 None,
6256 )
6257 .expect("Kimi route row");
6258
6259 assert_eq!(picker.selected_model.as_deref(), Some("k3"));
6260 assert_eq!(
6261 picker
6262 .model_options
6263 .iter()
6264 .filter(|model| model.eq_ignore_ascii_case("k3"))
6265 .count(),
6266 1,
6267 "the current wire model must be appended once, case-insensitively"
6268 );
6269
6270 config
6271 .providers
6272 .as_mut()
6273 .expect("providers")
6274 .moonshot
6275 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
6276 let generic = ProviderPickerView::new_for_model_pick_after_validation(
6277 ProviderKind::Moonshot,
6278 &(config).test_identity_for_kind(ProviderKind::Moonshot),
6279 &config,
6280 None,
6281 "validated-key".to_string(),
6282 None,
6283 )
6284 .expect("generic Moonshot row");
6285 assert!(
6286 !generic
6287 .model_options
6288 .iter()
6289 .any(|model| model.eq_ignore_ascii_case("k3")),
6290 "bare K3 stays route-local and must not be added to generic Moonshot"
6291 );
6292 }
6293
6294 #[test]
6295 fn setup_provider_key_entry_matrix_keeps_hosted_codex_and_local_hints_distinct() {
6296 let _guard = crate::test_support::lock_test_env();
6297 let tmp = tempfile::TempDir::new().expect("tempdir");
6298 let codewhale_home = tmp.path().join(".codewhale");
6299 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
6300 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
6301 let _codewhale_home =
6302 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
6303 let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
6304 let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
6305 let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
6306 let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
6307 let config = Config::default();
6308
6309 let hosted = ProviderPickerView::new_for_setup(
6310 ProviderKind::Openai,
6311 Some(ProviderKind::Deepseek.as_str().into()),
6312 &config,
6313 None,
6314 );
6315 assert_eq!(hosted.stage, Stage::KeyEntry);
6316 assert_eq!(hosted.selected_provider(), ProviderKind::Deepseek);
6317 let hosted_text = render_text(&hosted, 120, 20);
6318 assert!(hosted_text.contains("DEEPSEEK_API_KEY"), "{hosted_text}");
6319 assert!(
6320 hosted_text.contains("Credentials: https://platform.deepseek.com/api_keys"),
6321 "{hosted_text}"
6322 );
6323 assert!(!hosted_text.contains("OAuth login"), "{hosted_text}");
6324
6325 let codex = ProviderPickerView::new_for_setup(
6326 ProviderKind::Deepseek,
6327 Some(ProviderKind::OpenaiCodex.as_str().into()),
6328 &config,
6329 None,
6330 );
6331 assert_eq!(codex.stage, Stage::ChatgptAuthChoice);
6332 assert_eq!(codex.selected_provider(), ProviderKind::OpenaiCodex);
6333 let codex_text = render_text(&codex, 120, 20);
6334 assert!(codex_text.contains("Sign in with ChatGPT"), "{codex_text}");
6335 assert!(codex_text.contains("subscription"), "{codex_text}");
6336 assert!(codex_text.contains("openai"), "{codex_text}");
6337 assert!(codex_text.contains("API-key"), "{codex_text}");
6338 assert!(!codex_text.contains("Import Codex CLI"), "{codex_text}");
6339 assert!(!codex_text.contains("(paste key here)"), "{codex_text}");
6340
6341 let local = ProviderPickerView::new_for_setup(
6342 ProviderKind::Deepseek,
6343 Some(ProviderKind::Ollama.as_str().into()),
6344 &config,
6345 None,
6346 );
6347 assert_eq!(local.stage, Stage::List);
6348 assert_eq!(local.selected_provider(), ProviderKind::Ollama);
6349 let local_text = render_text(&local, 120, 20);
6350 assert!(!local_text.contains("Credentials:"), "{local_text}");
6351
6352 let mut custom = std::collections::HashMap::new();
6353 custom.insert(
6354 "my_thing".to_string(),
6355 crate::config::ProviderConfig {
6356 kind: Some("openai-compatible".to_string()),
6357 base_url: Some("https://api.example.com/v1".to_string()),
6358 model: Some("vendor/custom-model-v1".to_string()),
6359 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
6360 ..Default::default()
6361 },
6362 );
6363 let _custom_key = crate::test_support::EnvVarGuard::remove("EXAMPLE_API_KEY");
6364 let custom_config = Config {
6365 provider: Some("my_thing".to_string()),
6366 providers: Some(crate::config::ProvidersConfig {
6367 custom,
6368 ..Default::default()
6369 }),
6370 ..Config::default()
6371 };
6372 let custom_picker =
6373 ProviderPickerView::new_for_setup(ProviderKind::Custom, None, &custom_config, None);
6374 let custom_row = &custom_picker.rows[custom_picker.selected_idx];
6375 assert_eq!(custom_row.provider, ProviderKind::Custom);
6376 assert_eq!(custom_row.provider_id, "my_thing");
6377 assert!(
6378 custom_row
6379 .messages
6380 .iter()
6381 .any(|message| message.contains("EXAMPLE_API_KEY")),
6382 "custom setup row should name its configured auth env var: {:?}",
6383 custom_row.messages
6384 );
6385 let custom_text = render_text(&custom_picker, 120, 20);
6386 assert!(custom_text.contains("my_thing"), "{custom_text}");
6387 assert!(custom_text.contains("EXAMPLE_API_KEY"), "{custom_text}");
6388 assert!(!custom_text.contains("Credentials:"), "{custom_text}");
6389 }
6390
6391 #[test]
6392 fn provider_dashboard_row_models_local_readiness_without_rendering() {
6393 let config = Config {
6394 provider: Some("ollama".into()),
6395 ..Config::default()
6396 };
6397 let row =
6398 ProviderDashboardRow::from_config(ProviderKind::Ollama, ProviderKind::Ollama, &config);
6399
6400 assert_eq!(row.provider_id, "ollama");
6401 assert_eq!(row.auth_status, ProviderAuthStatus::Local);
6402 assert_eq!(row.readiness, ResolvedProviderReadiness::LocalUnchecked);
6403 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
6404 // Slice D: cost is model-level — a local model prices as local.
6405 assert_eq!(model_cost_label(ProviderKind::Ollama, "llama3"), "local");
6406 assert!(row.base_url.contains("localhost:11434"));
6407 assert!(row.is_active);
6408 }
6409
6410 #[test]
6411 fn ollama_cloud_row_requires_credentials_and_is_not_labeled_local() {
6412 let _env_lock = crate::test_support::lock_test_env();
6413 let temp = tempfile::tempdir().expect("isolated credential home");
6414 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
6415 let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
6416 let _ollama_cloud_key = EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
6417 let _ollama_key = EnvVarGuard::remove("OLLAMA_API_KEY");
6418 let _cli_source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
6419 let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
6420
6421 let mut config = Config {
6422 provider: Some("ollama".to_string()),
6423 providers: Some(crate::config::ProvidersConfig {
6424 ollama: crate::config::ProviderConfig {
6425 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
6426 ..Default::default()
6427 },
6428 ..Default::default()
6429 }),
6430 ..Default::default()
6431 };
6432
6433 assert_eq!(
6434 config.active_provider_identity().unwrap().provider,
6435 ProviderKind::OllamaCloud
6436 );
6437 let missing = ProviderDashboardRow::from_config(
6438 ProviderKind::OllamaCloud,
6439 ProviderKind::OllamaCloud,
6440 &config,
6441 );
6442 assert_eq!(missing.auth_status, ProviderAuthStatus::Missing);
6443 assert_eq!(missing.readiness, ResolvedProviderReadiness::MissingKey);
6444 // Slice D: no provider-level cost leaks into the catalog hint.
6445 assert!(!missing.detail_state_line().contains("cost:"));
6446 assert!(!missing.detail_state_line().contains("(self-hosted)"));
6447 assert!(
6448 missing
6449 .messages
6450 .iter()
6451 .any(|message| message.contains("OLLAMA_API_KEY")),
6452 "missing Cloud key guidance: {:?}",
6453 missing.messages
6454 );
6455
6456 config.providers.as_mut().expect("providers").ollama.api_key =
6457 Some("ollama-cloud-key".to_string());
6458 let configured = ProviderDashboardRow::from_config(
6459 ProviderKind::OllamaCloud,
6460 ProviderKind::OllamaCloud,
6461 &config,
6462 );
6463 assert_eq!(configured.auth_status, ProviderAuthStatus::Configured);
6464 assert_eq!(
6465 configured.readiness,
6466 ResolvedProviderReadiness::SavedUnchecked
6467 );
6468 assert!(!configured.detail_state_line().contains("(self-hosted)"));
6469 }
6470
6471 #[test]
6472 fn deepseek_cn_row_uses_shared_readiness_and_strict_model_validation() {
6473 let _lock = crate::test_support::lock_test_env();
6474 let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
6475 let missing = Config {
6476 provider: Some("deepseek-cn".to_string()),
6477 ..Default::default()
6478 };
6479 let missing_row = ProviderDashboardRow::from_config_with_provider_id(
6480 ProviderKind::Deepseek,
6481 ProviderKind::Deepseek,
6482 &missing,
6483 Some("deepseek-cn"),
6484 Some("deepseek-cn"),
6485 None,
6486 );
6487 assert_eq!(missing_row.readiness, ResolvedProviderReadiness::MissingKey);
6488 assert_ne!(missing_row.auth_status, ProviderAuthStatus::Legacy);
6489
6490 let configured = Config {
6491 provider: Some("deepseek-cn".to_string()),
6492 providers: Some(crate::config::ProvidersConfig {
6493 deepseek_cn: crate::config::ProviderConfig {
6494 api_key: Some("deepseek-cn-test-key".to_string()),
6495 model: Some("deepseek-v4-pro".to_string()),
6496 ..Default::default()
6497 },
6498 ..Default::default()
6499 }),
6500 ..Default::default()
6501 };
6502 let configured_row = ProviderDashboardRow::from_config_with_provider_id(
6503 ProviderKind::Deepseek,
6504 ProviderKind::Deepseek,
6505 &configured,
6506 Some("deepseek-cn"),
6507 Some("deepseek-cn"),
6508 None,
6509 );
6510 assert_eq!(
6511 configured_row.readiness,
6512 ResolvedProviderReadiness::SavedUnchecked
6513 );
6514
6515 let mut invalid = configured;
6516 invalid
6517 .providers
6518 .as_mut()
6519 .expect("providers")
6520 .deepseek_cn
6521 .model = Some("anthropic/claude-foreign".to_string());
6522 let invalid_row = ProviderDashboardRow::from_config_with_provider_id(
6523 ProviderKind::Deepseek,
6524 ProviderKind::Deepseek,
6525 &invalid,
6526 Some("deepseek-cn"),
6527 Some("deepseek-cn"),
6528 None,
6529 );
6530 assert_eq!(
6531 invalid_row.readiness,
6532 ResolvedProviderReadiness::InvalidRoute
6533 );
6534 }
6535
6536 #[test]
6537 fn provider_health_requires_observed_success_and_keeps_failure_reason() {
6538 let config = Config {
6539 ..Config::default()
6540 }
6541 .with_legacy_root(Some("saved-key".to_string()), None);
6542 let unchecked = ProviderPickerView::new(ProviderKind::Deepseek, &config);
6543 let row = unchecked
6544 .rows
6545 .iter()
6546 .find(|row| row.provider == ProviderKind::Deepseek)
6547 .expect("DeepSeek row");
6548 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
6549 // Readiness is per route identity (provider + endpoint + auth class +
6550 // model), so record the check against the row's own default-route
6551 // model. A hardcoded model literal goes stale whenever the provider's
6552 // default route moves — which is exactly what happened here.
6553 let row_model = row.default_route.logical_model.clone();
6554
6555 let mut health = ProviderReadinessSnapshot::default();
6556 health.record_success(
6557 &config,
6558 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
6559 &config,
6560 ProviderKind::Deepseek,
6561 &row_model,
6562 ),
6563 &row_model,
6564 );
6565 let ready =
6566 ProviderPickerView::new(ProviderKind::Deepseek, &config).with_provider_health(&health);
6567 assert_eq!(
6568 ready
6569 .rows
6570 .iter()
6571 .find(|row| row.provider == ProviderKind::Deepseek)
6572 .unwrap()
6573 .readiness,
6574 ResolvedProviderReadiness::Ready
6575 );
6576
6577 health.record_failure_message(
6578 &config,
6579 &(config).test_identity_for_kind(ProviderKind::Deepseek),
6580 &row_model,
6581 crate::error_taxonomy::ErrorCategory::Authentication,
6582 "credential rejected",
6583 );
6584 let failed =
6585 ProviderPickerView::new(ProviderKind::Deepseek, &config).with_provider_health(&health);
6586 let row = failed
6587 .rows
6588 .iter()
6589 .find(|row| row.provider == ProviderKind::Deepseek)
6590 .unwrap();
6591 assert!(row.readiness.label().contains("last check failed"));
6592 assert!(
6593 row.messages
6594 .iter()
6595 .any(|message| message == "credential rejected")
6596 );
6597 }
6598
6599 /// Release QA: a DeepSeek key from the environment that the provider
6600 /// refused stayed "key saved · not checked" (an ambient key has no
6601 /// read-only generation), and Enter re-applied the same key, looping
6602 /// send → setup → send. The rejection holds for every model on the route.
6603 #[test]
6604 fn rejected_env_key_is_marked_and_enter_asks_for_a_new_key() {
6605 let _lock = crate::test_support::lock_test_env();
6606 let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
6607 let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
6608 let _key = EnvVarGuard::set("DEEPSEEK_API_KEY", "sk-env-rejected");
6609 let config = Config::default();
6610 let identity = config.test_identity_for_kind(ProviderKind::Deepseek);
6611 assert!(
6612 config
6613 .readonly_health_credential_generation(&identity)
6614 .is_none(),
6615 "fixture must exercise the opaque ambient-key path"
6616 );
6617 let mut health = ProviderReadinessSnapshot::default();
6618 health.record_failure(
6619 &config,
6620 &crate::route_receipt::TurnRouteReceipt::for_test_fixture(
6621 &config,
6622 ProviderKind::Deepseek,
6623 "model-the-turn-ran",
6624 ),
6625 "model-the-turn-ran",
6626 &crate::error_taxonomy::ErrorEnvelope::fatal_auth(
6627 "Authentication failed: invalid API key",
6628 ),
6629 );
6630
6631 let mut picker = ProviderPickerView::new_for_onboarding(
6632 ProviderKind::Deepseek,
6633 Some(ProviderKind::Deepseek.as_str().into()),
6634 &config,
6635 None,
6636 )
6637 .with_provider_health(&health);
6638 assert_eq!(picker.selected_provider(), ProviderKind::Deepseek);
6639 let readiness = picker.rows[picker.selected_idx].readiness.clone();
6640 assert!(
6641 matches!(
6642 readiness,
6643 ResolvedProviderReadiness::SavedLastCheckFailed {
6644 category: crate::error_taxonomy::ErrorCategory::Authentication,
6645 ..
6646 }
6647 ),
6648 "{readiness:?}"
6649 );
6650 assert!(!format!("{readiness:?}").contains("sk-env-rejected"));
6651
6652 let action = picker.handle_key(key(KeyCode::Enter));
6653 assert!(
6654 matches!(action, ViewAction::None),
6655 "Enter must not re-apply the refused key"
6656 );
6657 assert_eq!(picker.stage, Stage::KeyEntry);
6658 assert!(picker.api_key_input.is_empty());
6659 }
6660
6661 #[test]
6662 fn openai_codex_row_is_experimental_and_tagged_in_hint() {
6663 let config = Config::default();
6664 let row = ProviderDashboardRow::from_config(
6665 ProviderKind::OpenaiCodex,
6666 ProviderKind::Deepseek,
6667 &config,
6668 );
6669
6670 // #2984: maturity is a separate axis from auth/readiness.
6671 assert_eq!(row.maturity, ProviderMaturity::Experimental);
6672 assert!(
6673 row.detail_state_line().contains("experimental"),
6674 "experimental maturity must surface in the hint, got {:?}",
6675 row.detail_state_line()
6676 );
6677 }
6678
6679 #[test]
6680 fn mainstream_provider_is_supported_without_experimental_tag() {
6681 let config = Config::default();
6682 let row = ProviderDashboardRow::from_config(
6683 ProviderKind::Deepseek,
6684 ProviderKind::Deepseek,
6685 &config,
6686 );
6687
6688 // #2984: supported integrations stay noise-free (no tag).
6689 assert_eq!(row.maturity, ProviderMaturity::Supported);
6690 assert!(
6691 !row.detail_state_line().contains("experimental"),
6692 "supported providers must omit the experimental tag, got {:?}",
6693 row.detail_state_line()
6694 );
6695 }
6696
6697 #[test]
6698 fn provider_dashboard_row_surfaces_glm_reasoning_controls() {
6699 let config = Config {
6700 reasoning_effort: Some("max".to_string()),
6701 providers: Some(crate::config::ProvidersConfig {
6702 zai: crate::config::ProviderConfig {
6703 api_key: Some("zai-key".to_string()),
6704 model: Some("GLM-5.2".to_string()),
6705 ..Default::default()
6706 },
6707 ..Default::default()
6708 }),
6709 ..Config::default()
6710 };
6711 let row = ProviderDashboardRow::from_config(ProviderKind::Zai, ProviderKind::Zai, &config);
6712
6713 assert_eq!(row.default_route.wire_model, "GLM-5.2");
6714 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
6715 assert_eq!(
6716 row.reasoning.controls,
6717 vec!["high".to_string(), "max".to_string()]
6718 );
6719 assert_eq!(
6720 row.reasoning.stream_visibility,
6721 ProviderReasoningStreamVisibility::StructuredThinking
6722 );
6723 assert_eq!(row.reasoning.selected_control.as_deref(), Some("max"));
6724 assert!(row.detail_facts().contains("reasoning:high/max"));
6725 assert!(row.detail_facts().contains("stream:structured"));
6726 }
6727
6728 #[test]
6729 fn provider_dashboard_row_surfaces_modelstudio_structured_thinking() {
6730 let config = Config {
6731 providers: Some(crate::config::ProvidersConfig {
6732 modelstudio_token_plan: crate::config::ProviderConfig {
6733 api_key: Some("modelstudio-key".to_string()),
6734 model: Some("qwen3.8-max".to_string()),
6735 ..Default::default()
6736 },
6737 ..Default::default()
6738 }),
6739 ..Config::default()
6740 };
6741 let row = ProviderDashboardRow::from_config(
6742 ProviderKind::ModelstudioTokenPlan,
6743 ProviderKind::ModelstudioTokenPlan,
6744 &config,
6745 );
6746
6747 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
6748 assert_eq!(
6749 row.reasoning.stream_visibility,
6750 ProviderReasoningStreamVisibility::StructuredThinking
6751 );
6752 assert!(row.detail_facts().contains("stream:structured"));
6753 }
6754
6755 #[test]
6756 fn provider_dashboard_row_surfaces_kimi_code_k3_reasoning_only_on_exact_route() {
6757 let config = Config {
6758 providers: Some(crate::config::ProvidersConfig {
6759 moonshot: crate::config::ProviderConfig {
6760 api_key: Some("kimi-code-key".to_string()),
6761 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
6762 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
6763 ..Default::default()
6764 },
6765 ..Default::default()
6766 }),
6767 ..Config::default()
6768 };
6769 let row = ProviderDashboardRow::from_config(
6770 ProviderKind::Moonshot,
6771 ProviderKind::Moonshot,
6772 &config,
6773 );
6774
6775 assert_eq!(
6776 row.default_route.wire_model,
6777 crate::config::KIMI_CODE_K3_MODEL
6778 );
6779 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
6780 assert_eq!(
6781 row.reasoning.stream_visibility,
6782 ProviderReasoningStreamVisibility::StructuredThinking
6783 );
6784 assert_eq!(
6785 row.reasoning.controls,
6786 vec!["low".to_string(), "high".to_string(), "max".to_string()]
6787 );
6788 assert_eq!(
6789 row.capabilities.context_window,
6790 Some(262_144),
6791 "the picker must show the route-effective K3 baseline, not the generic fallback"
6792 );
6793 assert_eq!(
6794 row.capabilities.context_window_source.as_deref(),
6795 Some("static Kimi Code safe floor"),
6796 "the picker must name the provenance instead of presenting a bare limit as provider fact"
6797 );
6798 assert!(
6799 row.detail_facts()
6800 .contains("ctx:262K(static Kimi Code safe floor)"),
6801 "the compact picker receipt must retain context provenance"
6802 );
6803
6804 let mut direct = config.clone();
6805 direct
6806 .providers
6807 .as_mut()
6808 .expect("providers")
6809 .moonshot
6810 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
6811 let direct_row = ProviderDashboardRow::from_config(
6812 ProviderKind::Moonshot,
6813 ProviderKind::Moonshot,
6814 &direct,
6815 );
6816 assert_ne!(
6817 direct_row.reasoning.support,
6818 ProviderReasoningSupport::Supported,
6819 "generic Moonshot k3 must not inherit Kimi Code's route-owned capability"
6820 );
6821 // The generic model-facts table now carries the same conservative
6822 // number for bare `k3`, so the route-ownership distinction lives in
6823 // provenance: the direct Moonshot row must never claim the Kimi Code
6824 // route-owned floor as its source.
6825 assert_ne!(
6826 direct_row.capabilities.context_window_source.as_deref(),
6827 Some("static Kimi Code safe floor")
6828 );
6829 }
6830
6831 #[test]
6832 fn provider_row_query_matches_default_route_model_and_wire_id() {
6833 // #4141: cross-field search must also match the default route's display
6834 // model name and wire model id, keeping this picker consistent with the
6835 // model picker (`model_row_matches_query`). Z.ai's provider key,
6836 // display name, kind, and base URL contain no "glm", so a "glm" match
6837 // can only come from the route's model/wire fields.
6838 let config = Config {
6839 providers: Some(crate::config::ProvidersConfig {
6840 zai: crate::config::ProviderConfig {
6841 api_key: Some("zai-key".to_string()),
6842 model: Some("GLM-5.2".to_string()),
6843 ..Default::default()
6844 },
6845 ..Default::default()
6846 }),
6847 ..Config::default()
6848 };
6849 let row = ProviderDashboardRow::from_config(ProviderKind::Zai, ProviderKind::Zai, &config);
6850 assert_eq!(row.default_route.wire_model, "GLM-5.2");
6851
6852 // Wire model id + display model name, case-insensitively.
6853 assert!(row.matches_query("glm-5.2"));
6854 assert!(row.matches_query("GLM"));
6855 // Provider name still matches, and an unrelated token still does not.
6856 assert!(row.matches_query("zhipu"));
6857 assert!(!row.matches_query("anthropic"));
6858 }
6859
6860 #[test]
6861 fn provider_dashboard_row_surfaces_zai_concurrency_cap() {
6862 let config = Config::default();
6863 let row =
6864 ProviderDashboardRow::from_config(ProviderKind::Zai, ProviderKind::Deepseek, &config);
6865
6866 assert_eq!(
6867 row.request_concurrency.limit,
6868 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
6869 );
6870 assert_eq!(row.request_concurrency.active, None);
6871 assert!(
6872 row.detail_facts().contains("req:cap 3"),
6873 "Z.ai's effective default cap must surface in /provider, got {:?}",
6874 row.detail_facts()
6875 );
6876 }
6877
6878 #[test]
6879 fn provider_dashboard_row_surfaces_active_provider_requests() {
6880 let config = Config {
6881 provider: Some("zai".into()),
6882 ..Config::default()
6883 };
6884 let runtime_status = ProviderRuntimeStatus {
6885 provider: ProviderKind::Zai,
6886 request_concurrency_limit: Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY),
6887 active_provider_requests: 2,
6888 };
6889 let mut picker = ProviderPickerView::new_with_runtime_status(
6890 ProviderKind::Zai,
6891 &config,
6892 Some(runtime_status),
6893 );
6894
6895 move_to_provider(&mut picker, ProviderKind::Zai);
6896 let row = &picker.rows[picker.selected_idx];
6897
6898 assert_eq!(
6899 row.request_concurrency.limit,
6900 Some(crate::config::DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY)
6901 );
6902 assert_eq!(row.request_concurrency.active, Some(2));
6903 assert!(
6904 row.detail_facts().contains("req:2/3"),
6905 "active runtime concurrency must surface in /provider, got {:?}",
6906 row.detail_facts()
6907 );
6908 }
6909
6910 #[test]
6911 fn provider_dashboard_row_surfaces_codex_reasoning_scale() {
6912 let config = Config {
6913 reasoning_effort: Some("max".to_string()),
6914 ..Config::default()
6915 };
6916 let row = ProviderDashboardRow::from_config(
6917 ProviderKind::OpenaiCodex,
6918 ProviderKind::OpenaiCodex,
6919 &config,
6920 );
6921
6922 assert_eq!(row.reasoning.support, ProviderReasoningSupport::Supported);
6923 assert_eq!(
6924 row.reasoning.controls,
6925 vec![
6926 "low".to_string(),
6927 "medium".to_string(),
6928 "high".to_string(),
6929 "max".to_string(),
6930 ]
6931 );
6932 assert_eq!(
6933 row.reasoning.stream_visibility,
6934 ProviderReasoningStreamVisibility::StructuredThinking
6935 );
6936 assert_eq!(row.reasoning.selected_control.as_deref(), Some("max"));
6937 assert!(row.detail_facts().contains("reasoning:low/medium/high/max"));
6938 }
6939
6940 #[test]
6941 fn provider_dashboard_row_surfaces_capability_and_metadata_badges() {
6942 let config = Config {
6943 providers: Some(crate::config::ProvidersConfig {
6944 deepseek: crate::config::ProviderConfig {
6945 api_key: Some("deepseek-key".to_string()),
6946 ..Default::default()
6947 },
6948 ..Default::default()
6949 }),
6950 ..Config::default()
6951 };
6952 let row = ProviderDashboardRow::from_config(
6953 ProviderKind::Deepseek,
6954 ProviderKind::Deepseek,
6955 &config,
6956 );
6957
6958 // Metadata badges are projected from the resolved capability profile,
6959 // never hardcoded per UI surface.
6960 assert!(row.capabilities.context_window.is_some());
6961 assert!(row.capabilities.max_output.is_some());
6962 let hint = row.detail_facts();
6963 assert!(hint.contains("ctx:"), "metadata badge missing: {hint}");
6964 assert!(hint.contains("out:"), "metadata badge missing: {hint}");
6965 // Capability cluster present (tri-state; unknown renders `?`, never
6966 // silently omitted).
6967 for badge in ["tools:", "json:", "stream:", "cache:"] {
6968 assert!(
6969 hint.contains(badge),
6970 "capability badge {badge} missing: {hint}"
6971 );
6972 }
6973 }
6974
6975 #[test]
6976 fn provider_dashboard_row_classifies_model_origin() {
6977 // Default: no configured model override.
6978 let config = Config::default();
6979 let row = ProviderDashboardRow::from_config(
6980 ProviderKind::Deepseek,
6981 ProviderKind::Deepseek,
6982 &config,
6983 );
6984 assert_eq!(row.model_origin, ProviderModelOrigin::Default);
6985 assert_eq!(row.model_origin, ProviderModelOrigin::Default);
6986
6987 // Saved: a configured model override for the provider.
6988 let config = Config {
6989 providers: Some(crate::config::ProvidersConfig {
6990 deepseek: crate::config::ProviderConfig {
6991 api_key: Some("k".to_string()),
6992 model: Some("deepseek-v4-flash".to_string()),
6993 ..Default::default()
6994 },
6995 ..Default::default()
6996 }),
6997 ..Config::default()
6998 };
6999 let row = ProviderDashboardRow::from_config(
7000 ProviderKind::Deepseek,
7001 ProviderKind::Deepseek,
7002 &config,
7003 );
7004 assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
7005 assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
7006 }
7007
7008 #[test]
7009 fn model_origin_classifier_covers_default_saved_custom() {
7010 assert_eq!(
7011 ProviderModelOrigin::for_provider(ProviderKind::Deepseek, false),
7012 ProviderModelOrigin::Default
7013 );
7014 assert_eq!(
7015 ProviderModelOrigin::for_provider(ProviderKind::Deepseek, true),
7016 ProviderModelOrigin::Saved
7017 );
7018 assert_eq!(
7019 ProviderModelOrigin::for_provider(ProviderKind::Custom, false),
7020 ProviderModelOrigin::Custom
7021 );
7022 // An explicit saved model still wins for a custom provider.
7023 assert_eq!(
7024 ProviderModelOrigin::for_provider(ProviderKind::Custom, true),
7025 ProviderModelOrigin::Saved
7026 );
7027 }
7028
7029 #[test]
7030 fn self_hosted_provider_row_marks_self_hosted_in_hint() {
7031 let _env_lock = crate::test_support::lock_test_env();
7032 let _sglang_key = crate::test_support::EnvVarGuard::remove("SGLANG_API_KEY");
7033 let _sglang_base_url = crate::test_support::EnvVarGuard::remove("SGLANG_BASE_URL");
7034 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
7035 let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
7036 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
7037 let _ollama_base_url = crate::test_support::EnvVarGuard::remove("OLLAMA_BASE_URL");
7038
7039 let config = Config::default();
7040 let row =
7041 ProviderDashboardRow::from_config(ProviderKind::Ollama, ProviderKind::Ollama, &config);
7042 assert_eq!(row.auth_status, ProviderAuthStatus::Local);
7043 assert!(
7044 row.detail_facts().contains("(self-hosted)"),
7045 "self-hosted hint missing: {}",
7046 row.detail_facts()
7047 );
7048
7049 let sglang =
7050 ProviderDashboardRow::from_config(ProviderKind::Sglang, ProviderKind::Sglang, &config);
7051 assert_eq!(sglang.auth_status, ProviderAuthStatus::Optional);
7052 assert!(
7053 sglang.detail_facts().contains("(self-hosted)"),
7054 "self-hosted hint missing for SGLang: {}",
7055 sglang.detail_facts()
7056 );
7057 }
7058
7059 #[test]
7060 fn protected_self_hosted_row_requires_its_configured_auth_mode() {
7061 let _env_lock = crate::test_support::lock_test_env();
7062 let temp = tempfile::tempdir().expect("isolated credential home");
7063 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
7064 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7065 let _vllm_key = crate::test_support::EnvVarGuard::remove("VLLM_API_KEY");
7066 let _vllm_base_url = crate::test_support::EnvVarGuard::remove("VLLM_BASE_URL");
7067 let config = Config {
7068 provider: Some("vllm".to_string()),
7069 providers: Some(crate::config::ProvidersConfig {
7070 vllm: crate::config::ProviderConfig {
7071 auth_mode: Some("api_key".to_string()),
7072 ..Default::default()
7073 },
7074 ..Default::default()
7075 }),
7076 ..Config::default()
7077 };
7078
7079 let row =
7080 ProviderDashboardRow::from_config(ProviderKind::Vllm, ProviderKind::Vllm, &config);
7081
7082 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7083 assert_eq!(row.credential_state, CredentialState::MissingKey);
7084 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7085 assert!(row.detail_facts().contains("(self-hosted)"));
7086 }
7087
7088 #[test]
7089 fn self_hosted_reasoning_visibility_covers_vllm() {
7090 assert_eq!(
7091 default_reasoning_stream_visibility(ProviderKind::Sglang),
7092 ProviderReasoningStreamVisibility::StructuredThinking
7093 );
7094 assert_eq!(
7095 default_reasoning_stream_visibility(ProviderKind::Vllm),
7096 ProviderReasoningStreamVisibility::StructuredThinking
7097 );
7098 }
7099
7100 #[test]
7101 fn humanize_token_count_is_compact_and_marks_unknown() {
7102 assert_eq!(humanize_token_count(None), "?");
7103 assert_eq!(humanize_token_count(Some(1_000_000)), "1M");
7104 assert_eq!(humanize_token_count(Some(1_500_000)), "1.5M");
7105 assert_eq!(humanize_token_count(Some(131_072)), "131K");
7106 assert_eq!(humanize_token_count(Some(512)), "512");
7107 }
7108
7109 #[test]
7110 fn provider_dashboard_row_uses_route_resolver_for_custom_openai_endpoint() {
7111 let config = Config {
7112 providers: Some(crate::config::ProvidersConfig {
7113 openai: crate::config::ProviderConfig {
7114 api_key: Some("openai-key".to_string()),
7115 base_url: Some("http://localhost:9000/v1".to_string()),
7116 model: Some("custom-model".to_string()),
7117 ..Default::default()
7118 },
7119 ..Default::default()
7120 }),
7121 ..Config::default()
7122 };
7123 let row =
7124 ProviderDashboardRow::from_config(ProviderKind::Openai, ProviderKind::Openai, &config);
7125
7126 assert_eq!(row.provider_id, "openai");
7127 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
7128 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
7129 assert_eq!(row.base_url, "http://localhost:9000/v1");
7130 assert_eq!(row.default_route.logical_model, "custom-model");
7131 assert_eq!(row.default_route.wire_model, "custom-model");
7132 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
7133 }
7134
7135 #[test]
7136 fn custom_endpoint_cannot_claim_official_xai_oauth_readiness() {
7137 let _lock = crate::test_support::lock_test_env();
7138 let temp = tempfile::tempdir().expect("isolated oauth home");
7139 let _xai_key = EnvVarGuard::remove("XAI_API_KEY");
7140 let missing_grok_auth = temp.path().join("missing.json");
7141 let _grok_auth = EnvVarGuard::set(
7142 "GROK_AUTH_PATH",
7143 missing_grok_auth.to_str().expect("utf8 test path"),
7144 );
7145 let config = Config {
7146 provider: Some("xai".to_string()),
7147 providers: Some(crate::config::ProvidersConfig {
7148 xai: crate::config::ProviderConfig {
7149 base_url: Some("https://gateway.example.test/v1".to_string()),
7150 model: Some("private-grok".to_string()),
7151 auth_mode: Some("oauth".to_string()),
7152 ..Default::default()
7153 },
7154 ..Default::default()
7155 }),
7156 ..Config::default()
7157 };
7158
7159 let row = ProviderDashboardRow::from_config(ProviderKind::Xai, ProviderKind::Xai, &config);
7160
7161 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7162 assert_eq!(row.credential_state, CredentialState::MissingKey);
7163 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7164 assert!(!row.detail_state_line().contains("oauth"));
7165 }
7166
7167 #[test]
7168 fn explicit_no_auth_custom_row_is_distinct_and_usable() {
7169 let custom = std::collections::HashMap::from([(
7170 "no-auth-gateway".to_string(),
7171 crate::config::ProviderConfig {
7172 kind: Some("openai-compatible".to_string()),
7173 base_url: Some("https://gateway.example.test/v1".to_string()),
7174 model: Some("private-model".to_string()),
7175 auth_mode: Some("no-auth".to_string()),
7176 ..Default::default()
7177 },
7178 )]);
7179 let config = Config {
7180 provider: Some("no-auth-gateway".to_string()),
7181 providers: Some(crate::config::ProvidersConfig {
7182 custom,
7183 ..Default::default()
7184 }),
7185 ..Config::default()
7186 };
7187
7188 let picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7189 let row = picker
7190 .rows
7191 .iter()
7192 .find(|row| row.provider_id == "no-auth-gateway")
7193 .expect("configured no-auth row");
7194
7195 assert_eq!(row.auth_status, ProviderAuthStatus::NoAuth);
7196 assert_eq!(row.credential_state, CredentialState::NoAuth);
7197 assert_eq!(row.readiness, ResolvedProviderReadiness::NoAuthUnchecked);
7198 assert!(picker.selected_has_key());
7199 assert!(row.detail_state_line().contains("auth:none"));
7200 }
7201
7202 #[test]
7203 fn unresolved_custom_auth_metadata_does_not_mark_picker_row_configured() {
7204 let custom = std::collections::HashMap::from([(
7205 "metadata-only".to_string(),
7206 crate::config::ProviderConfig {
7207 kind: Some("openai-compatible".to_string()),
7208 base_url: Some("https://gateway.example.test/v1".to_string()),
7209 model: Some("private-model".to_string()),
7210 auth: Some(codewhale_config::ProviderAuthSourceToml {
7211 source: codewhale_config::AuthSourceKind::Command,
7212 command: vec!["secret-tool".to_string(), "lookup".to_string()],
7213 timeout_ms: Some(2_000),
7214 secret_id: None,
7215 }),
7216 ..Default::default()
7217 },
7218 )]);
7219 let config = Config {
7220 provider: Some("metadata-only".to_string()),
7221 providers: Some(crate::config::ProvidersConfig {
7222 custom,
7223 ..Default::default()
7224 }),
7225 ..Config::default()
7226 };
7227
7228 let picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7229 let row = picker
7230 .rows
7231 .iter()
7232 .find(|row| row.provider_id == "metadata-only")
7233 .expect("metadata-only row remains visible for repair");
7234
7235 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7236 assert_eq!(row.credential_state, CredentialState::MissingKey);
7237 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7238 }
7239
7240 #[test]
7241 fn provider_picker_lists_configured_custom_provider_readiness() {
7242 let _lock = crate::test_support::lock_test_env();
7243 let _example_key = EnvVarGuard::remove("EXAMPLE_API_KEY");
7244 let mut custom = std::collections::HashMap::new();
7245 custom.insert(
7246 "my_thing".to_string(),
7247 crate::config::ProviderConfig {
7248 kind: Some("openai-compatible".to_string()),
7249 base_url: Some("https://api.example.com/v1".to_string()),
7250 model: Some("vendor/custom-model-v1".to_string()),
7251 api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
7252 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
7253 ..Default::default()
7254 },
7255 );
7256 let config = Config {
7257 provider: Some("my_thing".to_string()),
7258 providers: Some(crate::config::ProvidersConfig {
7259 custom,
7260 ..Default::default()
7261 }),
7262 ..Config::default()
7263 };
7264
7265 let picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7266 let row = picker
7267 .rows
7268 .iter()
7269 .find(|row| row.provider_id == "my_thing")
7270 .expect("configured custom provider row");
7271
7272 assert_eq!(row.provider, ProviderKind::Custom);
7273 assert_eq!(row.display_name, "my_thing (custom)");
7274 assert_eq!(row.kind, "openai-compatible");
7275 assert!(row.is_active);
7276 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7277 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7278 assert_eq!(row.base_url, "https://api.example.com/v1");
7279 assert_eq!(row.supported_protocols, vec!["chat".to_string()]);
7280 assert_eq!(row.default_route.logical_model, "vendor/custom-model-v1");
7281 assert_eq!(row.default_route.wire_model, "vendor/custom-model-v1");
7282 assert_eq!(row.model_origin, ProviderModelOrigin::Saved);
7283 assert!(
7284 row.messages
7285 .iter()
7286 .any(|message| message.contains("EXAMPLE_API_KEY")),
7287 "custom row should name the configured auth env var: {:?}",
7288 row.messages
7289 );
7290 assert_eq!(picker.rows[picker.selected_idx].provider_id, "my_thing");
7291 }
7292
7293 #[test]
7294 fn provider_picker_marks_only_exact_active_custom_row() {
7295 let custom = std::collections::HashMap::from([
7296 (
7297 "custom-a".to_string(),
7298 crate::config::ProviderConfig {
7299 kind: Some("openai-compatible".to_string()),
7300 base_url: Some("http://127.0.0.1:18181/v1".to_string()),
7301 model: Some("model-a".to_string()),
7302 api_key: Some("test-key-a".to_string()),
7303 ..Default::default()
7304 },
7305 ),
7306 (
7307 "custom-b".to_string(),
7308 crate::config::ProviderConfig {
7309 kind: Some("openai-compatible".to_string()),
7310 base_url: Some("http://127.0.0.1:18182/v1".to_string()),
7311 model: Some("model-b".to_string()),
7312 api_key: Some("test-key-b".to_string()),
7313 ..Default::default()
7314 },
7315 ),
7316 ]);
7317 let config = Config {
7318 provider: Some("custom-a".to_string()),
7319 providers: Some(crate::config::ProvidersConfig {
7320 custom,
7321 ..Default::default()
7322 }),
7323 ..Config::default()
7324 };
7325
7326 let rows = custom_provider_dashboard_rows(ProviderKind::Custom, &config, None);
7327 let active_ids: Vec<_> = rows
7328 .iter()
7329 .filter(|row| row.is_active)
7330 .map(|row| row.provider_id.as_str())
7331 .collect();
7332
7333 assert_eq!(active_ids, vec!["custom-a"]);
7334 }
7335
7336 #[test]
7337 fn provider_picker_marks_custom_provider_ready_when_env_auth_is_set() {
7338 let _lock = crate::test_support::lock_test_env();
7339 let _example_key = EnvVarGuard::set("EXAMPLE_API_KEY", "sk-test");
7340 let mut custom = std::collections::HashMap::new();
7341 custom.insert(
7342 "my_thing".to_string(),
7343 crate::config::ProviderConfig {
7344 kind: Some("openai-compatible".to_string()),
7345 base_url: Some("https://api.example.com/v1".to_string()),
7346 model: Some("custom-model-v1".to_string()),
7347 api_key_env: Some("EXAMPLE_API_KEY".to_string()),
7348 ..Default::default()
7349 },
7350 );
7351 let config = Config {
7352 provider: Some("my_thing".to_string()),
7353 providers: Some(crate::config::ProvidersConfig {
7354 custom,
7355 ..Default::default()
7356 }),
7357 ..Config::default()
7358 };
7359
7360 let picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7361 let row = picker
7362 .rows
7363 .iter()
7364 .find(|row| row.provider_id == "my_thing")
7365 .expect("configured custom provider row");
7366
7367 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
7368 assert_eq!(row.readiness, ResolvedProviderReadiness::SavedUnchecked);
7369 assert!(row.has_key);
7370 assert!(
7371 !row.messages
7372 .iter()
7373 .any(|message| message.contains("EXAMPLE_API_KEY")),
7374 "configured custom auth should not report missing env var: {:?}",
7375 row.messages
7376 );
7377 }
7378
7379 #[test]
7380 fn custom_provider_form_emits_named_provider_without_secret_value() {
7381 let config = Config::default();
7382 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7383
7384 assert!(matches!(
7385 picker.handle_key(key(KeyCode::Char('c'))),
7386 ViewAction::None
7387 ));
7388 assert_eq!(picker.stage, Stage::CustomForm);
7389 for ch in "acme_ai".chars() {
7390 picker.handle_key(key(KeyCode::Char(ch)));
7391 }
7392 picker.handle_key(key(KeyCode::Enter));
7393 for ch in "https://api.acme.example/v1".chars() {
7394 picker.handle_key(key(KeyCode::Char(ch)));
7395 }
7396 picker.handle_key(key(KeyCode::Enter));
7397 for ch in "acme/code-1".chars() {
7398 picker.handle_key(key(KeyCode::Char(ch)));
7399 }
7400 picker.handle_key(key(KeyCode::Enter));
7401 for ch in "ACME_API_KEY".chars() {
7402 picker.handle_key(key(KeyCode::Char(ch)));
7403 }
7404
7405 let action = picker.handle_key(key(KeyCode::Enter));
7406 match action {
7407 ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
7408 provider_id,
7409 base_url,
7410 model,
7411 api_key_env,
7412 }) => {
7413 assert_eq!(provider_id, "acme_ai");
7414 assert_eq!(base_url, "https://api.acme.example/v1");
7415 assert_eq!(model.as_deref(), Some("acme/code-1"));
7416 assert_eq!(api_key_env.as_deref(), Some("ACME_API_KEY"));
7417 }
7418 other => panic!("expected custom provider submit event, got {other:?}"),
7419 }
7420 }
7421
7422 /// Slice D two-pane picker at narrow widths: the provider strip stays on
7423 /// top and the priced models pane renders under it (stacked layout).
7424 /// Ollama carries no auth notes, so the pane fits the short detail area;
7425 /// note-heavy rows degrade by clipping, as detail panes always have.
7426 #[test]
7427 fn narrow_list_stage_stacks_models_pane_under_provider_strip() {
7428 let config = Config::default();
7429 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7430 move_to_provider(&mut picker, ProviderKind::Ollama);
7431 let rendered = render_text(&picker, 80, 52);
7432 assert!(rendered.contains("Models · price in/out"), "{rendered}");
7433 assert!(rendered.contains("(default)"), "{rendered}");
7434 assert!(rendered.contains("local"), "{rendered}");
7435 assert!(!rendered.contains("cost:"), "{rendered}");
7436 }
7437
7438 #[test]
7439 fn t_emits_test_connection_for_the_selected_row() {
7440 let config = Config::default();
7441 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7442 let expected_catalog = picker.view == ProviderListView::Catalog;
7443 let action = picker.handle_key(ctrl(KeyCode::Char('t')));
7444 match action {
7445 ViewAction::EmitAndClose(ViewEvent::ProviderPickerTestConnection {
7446 identity,
7447 catalog_view,
7448 }) => {
7449 let provider = identity.provider;
7450 let provider_id = identity.persisted_id();
7451 assert_eq!(provider, picker.selected_provider());
7452 assert_eq!(provider_id, picker.selected_provider_id().as_deref());
7453 assert_eq!(catalog_view, expected_catalog);
7454 }
7455 other => panic!("expected test-connection event, got {other:?}"),
7456 }
7457 }
7458
7459 #[test]
7460 fn t_on_configured_view_does_not_force_the_full_catalog() {
7461 let _lock = crate::test_support::lock_test_env();
7462 let _key = crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "sk-test");
7463 let config = Config::default();
7464 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7465 assert_eq!(picker.view, ProviderListView::Configured);
7466 match picker.handle_key(ctrl(KeyCode::Char('t'))) {
7467 ViewAction::EmitAndClose(ViewEvent::ProviderPickerTestConnection {
7468 catalog_view,
7469 ..
7470 }) => {
7471 assert!(!catalog_view);
7472 }
7473 other => panic!("expected test-connection event, got {other:?}"),
7474 }
7475 }
7476
7477 #[test]
7478 fn plain_t_stays_type_ahead_and_does_not_probe() {
7479 let config = Config::default();
7480 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7481 picker.toggle_view();
7482 let action = picker.handle_key(key(KeyCode::Char('t')));
7483 assert!(matches!(action, ViewAction::None));
7484 assert_eq!(picker.query, "t");
7485 assert_eq!(picker.stage, Stage::List);
7486 }
7487
7488 #[test]
7489 fn p_key_is_type_ahead_not_a_retired_template_list() {
7490 // #6289: the `p` template list is retired; `p` is ordinary
7491 // type-ahead like every other unbound letter.
7492 let config = Config::default();
7493 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7494 picker.toggle_view();
7495 let action = picker.handle_key(key(KeyCode::Char('p')));
7496 assert!(matches!(action, ViewAction::None));
7497 assert_eq!(picker.query, "p");
7498 assert_eq!(picker.stage, Stage::List);
7499 }
7500
7501 #[test]
7502 fn lm_studio_preset_is_loopback_keyless_and_requests_the_loaded_model() {
7503 let config = Config::default();
7504 let mut picker =
7505 ProviderPickerView::new_for_setup(ProviderKind::Deepseek, None, &config, None);
7506
7507 // `I` is no longer advertised in the footer — it was one of five
7508 // provider-specific setup forms given top-level keys out of forty
7509 // providers. The key still works for anyone who learned it, which is
7510 // exactly what the rest of this test proves.
7511 let rendered = render_text(&picker, 100, 28);
7512 assert!(!rendered.contains("I LM Studio"), "{rendered}");
7513 assert!(matches!(
7514 picker.handle_key(key(KeyCode::Char('i'))),
7515 ViewAction::None
7516 ));
7517 assert_eq!(picker.stage, Stage::CustomForm);
7518 assert_eq!(picker.custom_provider_field, CustomProviderField::Model);
7519 assert_eq!(picker.custom_provider_id, "lm_studio");
7520 assert_eq!(picker.custom_provider_base_url, "http://127.0.0.1:1234/v1");
7521 assert!(picker.custom_provider_model.is_empty());
7522 assert!(picker.custom_provider_api_key_env.is_empty());
7523
7524 for ch in "local-code-model".chars() {
7525 picker.handle_key(key(KeyCode::Char(ch)));
7526 }
7527 picker.handle_key(key(KeyCode::Enter));
7528 let action = picker.handle_key(key(KeyCode::Enter));
7529 match action {
7530 ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
7531 provider_id,
7532 base_url,
7533 model,
7534 api_key_env,
7535 }) => {
7536 assert_eq!(provider_id, "lm_studio");
7537 assert_eq!(base_url, "http://127.0.0.1:1234/v1");
7538 assert_eq!(model.as_deref(), Some("local-code-model"));
7539 assert_eq!(api_key_env, None);
7540 }
7541 other => panic!("expected LM Studio custom-provider submit event, got {other:?}"),
7542 }
7543 }
7544
7545 #[test]
7546 fn ds4_preset_is_keyless_and_ready_to_save() {
7547 let mut picker =
7548 ProviderPickerView::new_for_ds4_setup(ProviderKind::Deepseek, &Config::default(), None);
7549
7550 assert_eq!(picker.stage, Stage::CustomForm);
7551 assert_eq!(picker.custom_provider_id, "ds4");
7552 assert_eq!(picker.custom_provider_base_url, "http://127.0.0.1:8000/v1");
7553 assert_eq!(picker.custom_provider_model, "deepseek-v4-flash");
7554 assert!(picker.custom_provider_api_key_env.is_empty());
7555
7556 match picker.handle_key(key(KeyCode::Enter)) {
7557 ViewAction::EmitAndClose(ViewEvent::ProviderPickerCustomProviderSubmitted {
7558 provider_id,
7559 base_url,
7560 model,
7561 api_key_env,
7562 }) => {
7563 assert_eq!(provider_id, "ds4");
7564 assert_eq!(base_url, "http://127.0.0.1:8000/v1");
7565 assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
7566 assert_eq!(api_key_env, None);
7567 }
7568 other => panic!("expected DS4 custom-provider submit event, got {other:?}"),
7569 }
7570 }
7571
7572 #[test]
7573 fn named_custom_provider_selection_preserves_provider_id() {
7574 let mut custom = std::collections::HashMap::new();
7575 custom.insert(
7576 "local_acme".to_string(),
7577 crate::config::ProviderConfig {
7578 kind: Some("openai-compatible".to_string()),
7579 base_url: Some("http://localhost:9000/v1".to_string()),
7580 model: Some("acme/code-1".to_string()),
7581 ..Default::default()
7582 },
7583 );
7584 let config = Config {
7585 provider: Some("local_acme".to_string()),
7586 providers: Some(crate::config::ProvidersConfig {
7587 custom,
7588 ..Default::default()
7589 }),
7590 ..Config::default()
7591 };
7592 let mut picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7593
7594 let action = picker.handle_key(key(KeyCode::Enter));
7595
7596 match action {
7597 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { identity }) => {
7598 let provider = identity.provider;
7599 let provider_id = identity.persisted_id();
7600 assert_eq!(provider, ProviderKind::Custom);
7601 assert_eq!(provider_id, Some("local_acme"));
7602 }
7603 other => panic!("expected named custom provider apply, got {other:?}"),
7604 }
7605 }
7606
7607 #[test]
7608 fn named_custom_provider_model_shortcut_preserves_provider_id() {
7609 let mut custom = std::collections::HashMap::new();
7610 custom.insert(
7611 "local_acme".to_string(),
7612 crate::config::ProviderConfig {
7613 kind: Some("openai-compatible".to_string()),
7614 base_url: Some("http://localhost:9000/v1".to_string()),
7615 model: Some("acme/code-1".to_string()),
7616 ..Default::default()
7617 },
7618 );
7619 let config = Config {
7620 provider: Some("local_acme".to_string()),
7621 providers: Some(crate::config::ProvidersConfig {
7622 custom,
7623 ..Default::default()
7624 }),
7625 ..Config::default()
7626 };
7627 let mut picker = ProviderPickerView::new(ProviderKind::Custom, &config);
7628
7629 let action = picker.handle_key(key(KeyCode::Char('m')));
7630
7631 match action {
7632 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels { identity }) => {
7633 let provider = identity.provider;
7634 let provider_id = identity.persisted_id();
7635 assert_eq!(provider, ProviderKind::Custom);
7636 assert_eq!(provider_id, Some("local_acme"));
7637 }
7638 other => panic!("expected named custom provider model shortcut, got {other:?}"),
7639 }
7640 }
7641
7642 #[test]
7643 fn provider_dashboard_row_surfaces_anthropic_wire_protocol() {
7644 let config = Config::default();
7645 let row = ProviderDashboardRow::from_config(
7646 ProviderKind::Anthropic,
7647 ProviderKind::Deepseek,
7648 &config,
7649 );
7650
7651 assert_eq!(row.provider_id, "anthropic");
7652 assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
7653 assert_eq!(row.catalog_status, ProviderCatalogStatus::Bundled);
7654 assert!(row.available_model_count >= 3);
7655 }
7656
7657 #[test]
7658 fn provider_dashboard_row_surfaces_openmodel_messages_route() {
7659 let _lock = crate::test_support::lock_test_env();
7660 let _openmodel_key = EnvVarGuard::remove("OPENMODEL_API_KEY");
7661 let config = Config::default();
7662 let row = ProviderDashboardRow::from_config(
7663 ProviderKind::Openmodel,
7664 ProviderKind::Deepseek,
7665 &config,
7666 );
7667
7668 assert_eq!(row.provider_id, "openmodel");
7669 assert_eq!(row.display_name, "OpenModel");
7670 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7671 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7672 assert_eq!(row.supported_protocols, vec!["anthropic".to_string()]);
7673 assert_eq!(row.base_url, crate::config::DEFAULT_OPENMODEL_BASE_URL);
7674 assert_eq!(row.default_route.logical_model, "deepseek-v4-flash");
7675 assert_eq!(row.default_route.wire_model, "deepseek-v4-flash");
7676 assert!(
7677 row.messages
7678 .iter()
7679 .any(|message| message.contains("missing OPENMODEL_API_KEY"))
7680 );
7681 }
7682
7683 #[test]
7684 fn provider_dashboard_row_marks_missing_api_key_as_needs_key() {
7685 let _lock = crate::test_support::lock_test_env();
7686 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
7687 let config = Config::default();
7688 let row = ProviderDashboardRow::from_config(
7689 ProviderKind::Openrouter,
7690 ProviderKind::Deepseek,
7691 &config,
7692 );
7693
7694 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
7695 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
7696 assert_eq!(row.readiness.label(), "missing key");
7697 let hint = row.detail_state_line();
7698 assert!(hint.contains("missing key"));
7699 assert!(!hint.contains("key:not-set"));
7700 assert!(!hint.contains("needs-auth"));
7701 assert!(!hint.contains("auth:missing"));
7702 assert!(
7703 row.messages
7704 .iter()
7705 .any(|message| message.contains("missing OPENROUTER_API_KEY"))
7706 );
7707 }
7708
7709 /// The visible payoff of the sourced resolver. Before this change the row
7710 /// said "missing OPENROUTER_API_KEY" and nothing else, so a user could not
7711 /// tell whether the durable slot had been read and found empty, skipped, or
7712 /// never consulted. The note must now name the places that were probed and
7713 /// the command that fixes the first of them.
7714 #[test]
7715 fn missing_key_note_names_the_places_that_were_checked() {
7716 let _lock = crate::test_support::lock_test_env();
7717 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
7718 let config = Config::default();
7719 let row = ProviderDashboardRow::from_config(
7720 ProviderKind::Openrouter,
7721 ProviderKind::Deepseek,
7722 &config,
7723 );
7724
7725 let note = row
7726 .messages
7727 .iter()
7728 .find(|message| message.contains("missing OPENROUTER_API_KEY"))
7729 .expect("missing-key note");
7730 assert!(
7731 note.contains("checked "),
7732 "the note must say where it looked: {note}"
7733 );
7734 assert!(
7735 note.contains("secret store \"openrouter\""),
7736 "the durable slot must be named: {note}"
7737 );
7738 assert!(
7739 note.contains("fix: "),
7740 "the note must offer an action: {note}"
7741 );
7742 assert!(
7743 !note.contains("sk-"),
7744 "a credential note must never carry key material: {note}"
7745 );
7746 }
7747
7748 /// Every row states which place its credential came from, so a
7749 /// "key:configured" row can be reconciled with a request that used a
7750 /// different source.
7751 #[test]
7752 fn every_row_states_its_credential_source() {
7753 let _lock = crate::test_support::lock_test_env();
7754 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
7755 let config = Config::default();
7756 let missing = ProviderDashboardRow::from_config(
7757 ProviderKind::Openrouter,
7758 ProviderKind::Deepseek,
7759 &config,
7760 );
7761 assert_eq!(missing.credential_source, "not found");
7762
7763 let _key = EnvVarGuard::set("OPENROUTER_API_KEY", "test-value");
7764 let configured = ProviderDashboardRow::from_config(
7765 ProviderKind::Openrouter,
7766 ProviderKind::Deepseek,
7767 &config,
7768 );
7769 assert_eq!(configured.credential_source, "OPENROUTER_API_KEY");
7770 assert!(
7771 !configured.credential_source.contains("test-value"),
7772 "the source is a label, never the value"
7773 );
7774 }
7775
7776 #[test]
7777 fn modelstudio_family_key_marks_all_variants_configured() {
7778 let _guard = crate::test_support::lock_test_env();
7779 let tmp = tempfile::TempDir::new().expect("tempdir");
7780 let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
7781 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
7782 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
7783 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7784 let _ms_key = crate::test_support::EnvVarGuard::remove("MODELSTUDIO_API_KEY");
7785 let _dashscope_key = crate::test_support::EnvVarGuard::remove("DASHSCOPE_API_KEY");
7786 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
7787 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
7788
7789 // One saved key on the Token Plan variant, marked by the save path.
7790 codewhale_secrets::Secrets::auto_detect()
7791 .set("modelstudio-token-plan", "ms-family-key")
7792 .expect("seed family slot");
7793 let config = Config {
7794 provider: Some("deepseek".to_string()),
7795 providers: Some(crate::config::ProvidersConfig {
7796 modelstudio_token_plan: crate::config::ProviderConfig {
7797 auth_mode: Some("api_key".to_string()),
7798 ..Default::default()
7799 },
7800 ..Default::default()
7801 }),
7802 ..Config::default()
7803 };
7804
7805 for variant in [
7806 ProviderKind::ModelstudioTokenPlan,
7807 ProviderKind::ModelstudioTokenPlanAnthropic,
7808 ProviderKind::ModelstudioCodingPlan,
7809 ProviderKind::ModelstudioCodingPlanAnthropic,
7810 ] {
7811 let row = ProviderDashboardRow::from_config(variant, ProviderKind::Deepseek, &config);
7812 assert_eq!(
7813 row.auth_status,
7814 ProviderAuthStatus::Configured,
7815 "{variant:?} must resolve the family's one saved key"
7816 );
7817 }
7818 }
7819
7820 #[test]
7821 fn provider_dashboard_row_marks_route_resolver_errors_as_invalid() {
7822 let config = Config {
7823 providers: Some(crate::config::ProvidersConfig {
7824 deepseek: crate::config::ProviderConfig {
7825 model: Some("anthropic/claude-foreign".to_string()),
7826 ..Default::default()
7827 },
7828 ..Default::default()
7829 }),
7830 ..Config::default()
7831 }
7832 .with_legacy_root(Some("deepseek-key".to_string()), None);
7833 let row = ProviderDashboardRow::from_config(
7834 ProviderKind::Deepseek,
7835 ProviderKind::Deepseek,
7836 &config,
7837 );
7838
7839 assert_eq!(row.auth_status, ProviderAuthStatus::Configured);
7840 assert_eq!(row.readiness, ResolvedProviderReadiness::InvalidRoute);
7841 assert_eq!(row.default_route.wire_model, "unresolved");
7842 assert!(
7843 row.messages
7844 .iter()
7845 .any(|message| message.contains("route validation failed"))
7846 );
7847 }
7848
7849 #[test]
7850 fn provider_dashboard_keeps_route_and_prices_visible_with_protocol_in_details() {
7851 let config = Config {
7852 provider: Some("openai".into()),
7853 providers: Some(crate::config::ProvidersConfig {
7854 openai: crate::config::ProviderConfig {
7855 api_key: Some("openai-key".to_string()),
7856 auth_mode: Some("api_key".into()),
7857 base_url: Some("http://localhost:9000/v1".to_string()),
7858 model: Some("custom-model".to_string()),
7859 ..Default::default()
7860 },
7861 ..Default::default()
7862 }),
7863 ..Config::default()
7864 };
7865 let picker = ProviderPickerView::new(ProviderKind::Openai, &config);
7866
7867 let rendered = render_text(&picker, 124, 24);
7868
7869 assert!(rendered.contains("key saved"));
7870 assert!(!rendered.contains("key:configured"));
7871 assert!(!rendered.contains("auth:configured"));
7872 assert!(rendered.contains("Model: custom-model"));
7873 let ViewAction::Emit(ViewEvent::OpenTextPager { content, .. }) =
7874 picker.open_provider_details()
7875 else {
7876 panic!("protocol details must remain accessible")
7877 };
7878 assert!(content.contains("Protocol: chat"));
7879 assert!(rendered.contains(picker.tr(MessageId::CtxMenuOpenDetails).as_ref()));
7880 // Slice D: provider detail carries no cost; the models pane does.
7881 assert!(!rendered.contains("cost:"));
7882 assert!(!rendered.contains("Usage:"));
7883 assert!(rendered.contains("Models · price in/out"));
7884 assert!(rendered.contains("Endpoint: http://localhost:9000/v1"));
7885 }
7886
7887 #[test]
7888 fn ollama_is_selectable_without_key() {
7889 let config = Config::default();
7890 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7891 move_to_provider(&mut picker, ProviderKind::Ollama);
7892 assert_eq!(picker.selected_provider(), ProviderKind::Ollama);
7893 assert!(picker.selected_has_key());
7894 let action = picker.handle_key(key(KeyCode::Enter));
7895 match action {
7896 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { identity }) => {
7897 let provider = identity.provider;
7898 let provider_id = identity.persisted_id();
7899 assert_eq!(provider, ProviderKind::Ollama);
7900 assert_eq!(provider_id, Some(provider.as_str()));
7901 }
7902 other => panic!("expected ProviderPickerApplied, got {other:?}"),
7903 }
7904 }
7905
7906 #[test]
7907 fn pressing_m_opens_models_for_selected_provider() {
7908 let config = Config::default();
7909 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7910 move_to_provider(&mut picker, ProviderKind::Openrouter);
7911
7912 let action = picker.handle_key(key(KeyCode::Char('m')));
7913
7914 // #3083: `m` jumps to the model picker scoped to the highlighted
7915 // provider rather than acting as a type-ahead seek.
7916 match action {
7917 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels { identity }) => {
7918 let provider = identity.provider;
7919 let provider_id = identity.persisted_id();
7920 assert_eq!(provider, ProviderKind::Openrouter);
7921 assert_eq!(provider_id, Some(provider.as_str()));
7922 }
7923 other => panic!("expected ProviderPickerOpenModels, got {other:?}"),
7924 }
7925 }
7926
7927 #[test]
7928 fn pressing_uppercase_m_also_opens_models() {
7929 let config = Config::default();
7930 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7931
7932 // Case-insensitive like the `R` edit-key affordance: a bare `M` works.
7933 let action = picker.handle_key(key(KeyCode::Char('M')));
7934
7935 match action {
7936 ViewAction::EmitAndClose(ViewEvent::ProviderPickerOpenModels { identity }) => {
7937 let provider = identity.provider;
7938 let provider_id = identity.persisted_id();
7939 assert_eq!(provider, ProviderKind::Deepseek);
7940 assert_eq!(provider_id, Some(provider.as_str()));
7941 }
7942 other => panic!("expected ProviderPickerOpenModels, got {other:?}"),
7943 }
7944 }
7945
7946 #[test]
7947 fn picker_marks_active_provider_as_initial_selection() {
7948 let config = Config {
7949 provider: Some("openrouter".into()),
7950 ..Config::default()
7951 };
7952 let picker = ProviderPickerView::new(ProviderKind::Openrouter, &config);
7953 assert_eq!(picker.selected_provider(), ProviderKind::Openrouter);
7954 assert!(picker.rows[picker.selected_idx].is_active);
7955 }
7956
7957 #[test]
7958 fn list_navigation_wraps_between_first_and_last_provider() {
7959 let config = Config::default();
7960 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7961 // Wrap across the full catalog (#3830), not just the configured
7962 // subset, which would only contain the active provider here.
7963 picker.toggle_view();
7964 let first = picker.rows.first().expect("non-empty list").provider;
7965 let last = picker.rows.last().expect("non-empty list").provider;
7966
7967 // Order-independent: jump to the first entry, wrap up to the last, back down.
7968 picker.selected_idx = 0;
7969 picker.handle_key(key(KeyCode::Up));
7970 assert_eq!(picker.selected_provider(), last);
7971
7972 picker.handle_key(key(KeyCode::Down));
7973 assert_eq!(picker.selected_provider(), first);
7974 }
7975
7976 #[test]
7977 fn page_and_edge_motions_clamp_and_never_land_on_hidden_rows() {
7978 let config = Config::default();
7979 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
7980 picker.toggle_view(); // full catalog (#3830)
7981 let last = picker.rows.len() - 1;
7982
7983 picker.selected_idx = 0;
7984 picker.handle_key(key(KeyCode::PageDown));
7985 assert_eq!(
7986 picker.selected_idx,
7987 PROVIDER_PAGE.min(last),
7988 "PageDown travels one page and clamps at the end"
7989 );
7990 picker.handle_key(key(KeyCode::End));
7991 assert_eq!(picker.selected_idx, last, "End is the last visible row");
7992 picker.handle_key(key(KeyCode::PageDown));
7993 assert_eq!(
7994 picker.selected_idx, last,
7995 "paging at the end clamps, never wraps"
7996 );
7997 picker.handle_key(key(KeyCode::PageUp));
7998 assert_eq!(picker.selected_idx, last.saturating_sub(PROVIDER_PAGE));
7999 picker.handle_key(key(KeyCode::Home));
8000 assert_eq!(picker.selected_idx, 0, "Home is the first visible row");
8001
8002 // With a live filter, motions land only on rows it shows.
8003 picker.update_query("deep".to_string());
8004 picker.handle_key(key(KeyCode::End));
8005 assert!(
8006 picker.row_visible(picker.selected_idx),
8007 "End must skip rows the filter hides"
8008 );
8009 let last_visible = (0..picker.rows.len())
8010 .rev()
8011 .find(|&index| picker.row_visible(index))
8012 .expect("a filter matching something");
8013 assert_eq!(picker.selected_idx, last_visible);
8014 picker.handle_key(key(KeyCode::Home));
8015 let first_visible = (0..picker.rows.len())
8016 .find(|&index| picker.row_visible(index))
8017 .expect("a filter matching something");
8018 assert_eq!(picker.selected_idx, first_visible);
8019 }
8020
8021 #[test]
8022 fn enter_with_no_key_transitions_to_key_entry_stage() {
8023 let config = Config::default();
8024 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8025 // Move to OpenRouter, which has no key in default config.
8026 move_to_provider(&mut picker, ProviderKind::Openrouter);
8027 assert_eq!(picker.selected_provider(), ProviderKind::Openrouter);
8028 let action = picker.handle_key(key(KeyCode::Enter));
8029 assert!(matches!(action, ViewAction::None));
8030 assert_eq!(picker.stage, Stage::KeyEntry);
8031 }
8032
8033 #[test]
8034 fn enter_with_existing_key_emits_apply_and_closes() {
8035 let config = Config {
8036 ..Config::default()
8037 }
8038 .with_legacy_root(Some("existing-deepseek-key".to_string()), None);
8039 let mut picker = ProviderPickerView::new(ProviderKind::NvidiaNim, &config);
8040 // Navigate to DeepSeek, which has a key from the top-level config.
8041 move_to_provider(&mut picker, ProviderKind::Deepseek);
8042 let action = picker.handle_key(key(KeyCode::Enter));
8043 match action {
8044 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { identity }) => {
8045 let provider = identity.provider;
8046 let provider_id = identity.persisted_id();
8047 assert_eq!(provider, ProviderKind::Deepseek);
8048 assert_eq!(provider_id, Some(provider.as_str()));
8049 }
8050 other => panic!("expected ProviderPickerApplied, got {other:?}"),
8051 }
8052 }
8053
8054 #[test]
8055 fn new_for_missing_auth_opens_key_entry_focused_on_target() {
8056 // #3830: the missing-auth handoff drops the user onto the target
8057 // provider's key prompt, not a dead-end error.
8058 let config = Config::default();
8059 let picker = ProviderPickerView::new_for_missing_auth(
8060 ProviderKind::Deepseek,
8061 &(config).test_identity_for_kind(ProviderKind::Anthropic),
8062 &config,
8063 None,
8064 )
8065 .expect("Anthropic has a picker row");
8066 assert_eq!(picker.stage, Stage::KeyEntry);
8067 assert_eq!(picker.selected_provider(), ProviderKind::Anthropic);
8068 }
8069
8070 #[test]
8071 fn setup_catalog_shows_all_providers_from_configured_view() {
8072 let config = Config::default();
8073 let picker = ProviderPickerView::new_for_setup(ProviderKind::Deepseek, None, &config, None);
8074
8075 assert_eq!(picker.stage, Stage::List);
8076 assert_eq!(picker.view, ProviderListView::Catalog);
8077 assert_eq!(picker.visible_row_count(), picker.rows.len());
8078 let mut listed = picker
8079 .rows
8080 .iter()
8081 .map(|row| row.provider)
8082 .collect::<Vec<_>>();
8083 // With no configured custom providers, the catalog keeps the Custom
8084 // entry so a custom endpoint can still be created from setup. The
8085 // canonical universe is the user-facing catalog (one identity per
8086 // vendor): dual-wire dialects are `wire` config and plan variants are
8087 // `mode`/base_url, not picker rows. Setup templates are retired
8088 // (#6289), so every row is a first-class provider.
8089 let mut expected = ProviderKind::all().to_vec();
8090 // Plus one `Custom` row per bundled compatible-host descriptor
8091 // (#6289) — setup is where those hosts are found and configured.
8092 expected.extend(
8093 bundled_provider_descriptors()
8094 .iter()
8095 .map(|_| ProviderKind::Custom),
8096 );
8097 listed.sort_by_key(|provider| provider.as_str());
8098 expected.sort_by_key(|provider| provider.as_str());
8099 assert_eq!(
8100 listed, expected,
8101 "setup must use the canonical provider universe"
8102 );
8103 }
8104
8105 #[test]
8106 fn setup_catalog_focuses_missing_provider_key_entry() {
8107 let _lock = crate::test_support::lock_test_env();
8108 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
8109 let config = Config::default();
8110 let picker = ProviderPickerView::new_for_setup(
8111 ProviderKind::Deepseek,
8112 Some(ProviderKind::Anthropic.as_str().into()),
8113 &config,
8114 None,
8115 );
8116
8117 assert_eq!(picker.view, ProviderListView::Catalog);
8118 assert_eq!(picker.stage, Stage::KeyEntry);
8119 assert_eq!(picker.selected_provider(), ProviderKind::Anthropic);
8120 assert!(picker.api_key_input.is_empty());
8121 }
8122
8123 /// #4763: onboarding focuses the persisted route but must still open on
8124 /// the navigable list. Jumping straight into key/OAuth entry hid the
8125 /// provider catalog from returning users with a missing key.
8126 #[test]
8127 fn onboarding_catalog_focuses_missing_provider_without_leaving_the_list() {
8128 let _lock = crate::test_support::lock_test_env();
8129 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
8130 let config = Config::default();
8131 let picker = ProviderPickerView::new_for_onboarding(
8132 ProviderKind::Deepseek,
8133 Some(ProviderKind::Anthropic.as_str().into()),
8134 &config,
8135 None,
8136 );
8137
8138 assert_eq!(picker.stage, Stage::List);
8139 assert_eq!(picker.view, ProviderListView::Catalog);
8140 assert_eq!(picker.selected_provider(), ProviderKind::Anthropic);
8141 assert_eq!(
8142 picker.visible_row_count(),
8143 picker.rows.len(),
8144 "onboarding must show the whole provider catalog"
8145 );
8146 }
8147
8148 fn with_first_run_config(document: &str, check: impl FnOnce(crate::tui::app::App, Config)) {
8149 let _lock = crate::test_support::lock_test_env();
8150 let home = tempfile::tempdir().expect("fresh home");
8151 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
8152 let config_path = home.path().join("config.toml");
8153 let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
8154 let _env: Vec<_> = [
8155 "CODEWHALE_PROVIDER",
8156 "DEEPSEEK_PROVIDER",
8157 "CODEWHALE_MODEL",
8158 "DEEPSEEK_MODEL",
8159 "DEEPSEEK_API_KEY",
8160 "OPENAI_API_KEY",
8161 "OPENAI_BASE_URL",
8162 "DEEPSEEK_BASE_URL",
8163 ]
8164 .into_iter()
8165 .map(EnvVarGuard::remove)
8166 .collect();
8167 std::fs::write(&config_path, document).expect("config only");
8168 let config = Config::load(Some(config_path.clone()), None).expect("load route");
8169 let mut options = crate::test_support::test_tui_options(home.path());
8170 options.config_path = Some(config_path);
8171 options.skip_onboarding = false;
8172 check(crate::tui::app::App::new(options, &config), config);
8173 }
8174
8175 #[test]
8176 fn first_run_configured_route_skips_picker_and_records_configuration() {
8177 for key in ["api_key = \"fixture-not-a-key\"", ""] {
8178 let document = format!(
8179 "provider = \"openai\"\ndefault_text_model = \"gpui-fixture\"\n\
8180 [providers.openai]\n{key}\nbase_url = \"http://127.0.0.1:4880/v1\"\n"
8181 );
8182 with_first_run_config(&document, |mut app, _config| {
8183 assert_eq!(app.api_provider, ProviderKind::Openai);
8184 assert_eq!(app.model, "gpui-fixture");
8185 assert_eq!(app.active_route_base_url, "http://127.0.0.1:4880/v1");
8186 assert_eq!(app.onboarding, crate::tui::app::OnboardingState::None);
8187 assert!(!crate::local_ollama::should_adopt_live_local_ollama(
8188 &mut app
8189 ));
8190
8191 let runtime = tokio::runtime::Runtime::new().unwrap();
8192 runtime
8193 .block_on(crate::tui::setup::record_configured_route(&app))
8194 .unwrap();
8195 let state = codewhale_config::SetupState::load().unwrap().unwrap();
8196 let entry = &state.steps[&codewhale_config::SetupStep::ProviderModel];
8197 assert_eq!(entry.status, codewhale_config::StepStatus::Configured);
8198 assert!(entry.status.is_settled());
8199 let result = entry.result.as_deref().unwrap();
8200 assert!(result.contains("provider=openai, model=gpui-fixture"));
8201 assert!(result.contains("not checked"));
8202 assert!(!result.contains("fixture-not-a-key"));
8203 });
8204 }
8205 }
8206
8207 #[test]
8208 fn first_run_configured_provider_is_preselected_when_picker_is_needed() {
8209 with_first_run_config(
8210 "provider = \"openai\"\ndefault_text_model = \"gpui-fixture\"\n\
8211 [providers.openai]\nbase_url = \"https://fixture.invalid/v1\"\nauth_mode = \"api_key\"\n",
8212 |app, config| {
8213 assert_eq!(app.onboarding, crate::tui::app::OnboardingState::Provider);
8214 assert!(app.onboarding_recovers_configured_route());
8215 let picker = ProviderPickerView::new_for_onboarding(
8216 app.api_provider,
8217 app.onboarding_recovers_configured_route()
8218 .then(|| app.onboarding_provider.as_str().into()),
8219 &config,
8220 None,
8221 );
8222 assert_eq!(picker.stage, Stage::List);
8223 assert_eq!(picker.selected_provider(), ProviderKind::Openai);
8224 },
8225 );
8226 }
8227
8228 #[test]
8229 fn first_run_unconfigured_route_keeps_picker_and_local_discovery() {
8230 with_first_run_config("", |mut app, config| {
8231 assert_eq!(app.onboarding, crate::tui::app::OnboardingState::Provider);
8232 assert!(!app.onboarding_recovers_configured_route());
8233 assert!(crate::local_ollama::should_adopt_live_local_ollama(
8234 &mut app
8235 ));
8236 let picker =
8237 ProviderPickerView::new_for_onboarding(app.api_provider, None, &config, None);
8238 assert_eq!(picker.stage, Stage::List);
8239 assert_eq!(picker.selected_provider(), ProviderKind::Deepseek);
8240 let runtime = tokio::runtime::Runtime::new().unwrap();
8241 runtime
8242 .block_on(crate::tui::setup::record_configured_route(&app))
8243 .unwrap();
8244 assert!(codewhale_config::SetupState::load().unwrap().is_none());
8245 });
8246 }
8247
8248 #[test]
8249 fn first_run_configured_receipt_preserves_decisions_and_rejects_corrupt_state() {
8250 use codewhale_config::{SetupState, SetupStep, StepEntry, StepStatus};
8251 with_first_run_config(
8252 "provider = \"openai\"\ndefault_text_model = \"gpui-fixture\"\n\
8253 [providers.openai]\nbase_url = \"http://127.0.0.1:4880/v1\"\n",
8254 |app, _config| {
8255 let runtime = tokio::runtime::Runtime::new().unwrap();
8256 let mut state = SetupState::default();
8257 state.record_telemetry_notice("4", false);
8258 state.save().unwrap();
8259 runtime
8260 .block_on(crate::tui::setup::record_configured_route(&app))
8261 .unwrap();
8262 let saved = SetupState::load().unwrap().unwrap();
8263 assert!(saved.telemetry_opted_out());
8264 assert_eq!(
8265 saved.status(SetupStep::ProviderModel),
8266 StepStatus::Configured
8267 );
8268
8269 for status in [StepStatus::Verified, StepStatus::NeedsAction] {
8270 state.set_step(
8271 SetupStep::ProviderModel,
8272 StepEntry::new(status, true, "test"),
8273 );
8274 state.save().unwrap();
8275 runtime
8276 .block_on(crate::tui::setup::record_configured_route(&app))
8277 .unwrap();
8278 assert_eq!(SetupState::load().unwrap().unwrap(), state);
8279 }
8280 let path = SetupState::path().unwrap();
8281 std::fs::write(&path, "not-json").unwrap();
8282 assert!(
8283 runtime
8284 .block_on(crate::tui::setup::record_configured_route(&app))
8285 .is_err()
8286 );
8287 assert_eq!(std::fs::read_to_string(path).unwrap(), "not-json");
8288 },
8289 );
8290 }
8291
8292 #[test]
8293 fn first_run_onboarding_shows_all_providers_including_hosted() {
8294 let _lock = crate::test_support::lock_test_env();
8295 let config = Config::default();
8296 let picker =
8297 ProviderPickerView::new_for_onboarding(ProviderKind::Deepseek, None, &config, None);
8298
8299 // First-run setup opens on the full catalog with the active provider
8300 // (DeepSeek) selected — hosted APIs are visible immediately, not
8301 // hidden behind a keypress (#n3onr1ft feedback, 2026-08-23).
8302 assert_eq!(picker.stage, Stage::List);
8303 assert_eq!(picker.view, ProviderListView::Catalog);
8304 assert_eq!(picker.selected_provider(), ProviderKind::Deepseek);
8305
8306 let visible = picker
8307 .filtered_rows()
8308 .into_iter()
8309 .map(|(_, row)| row.provider)
8310 .collect::<Vec<_>>();
8311 assert!(!visible.is_empty());
8312 // Hosted providers AND local runtimes are both present up front.
8313 assert!(visible.contains(&ProviderKind::Deepseek));
8314 assert!(visible.contains(&ProviderKind::Ollama));
8315 assert!(
8316 visible
8317 .iter()
8318 .any(|provider| provider.provider().credential_help().acquisition
8319 != codewhale_config::provider::CredentialAcquisition::LocalOptional),
8320 "hosted providers must be visible on first run: {visible:?}"
8321 );
8322
8323 let rendered = render_text(&picker, 40, 12);
8324 assert!(
8325 rendered.contains(crate::tui::glyphs::SELECTION),
8326 "{rendered}"
8327 );
8328 for (idx, line) in rendered.lines().enumerate() {
8329 assert!(
8330 crate::tui::ui_text::text_display_width(line) <= 40,
8331 "40x12 line {idx} clips: {line:?}\n{rendered}"
8332 );
8333 }
8334 }
8335
8336 #[test]
8337 fn local_shortcut_filters_cloud_rows_from_the_catalog() {
8338 let config = Config::default();
8339 let mut picker = ProviderPickerView::new_for_onboarding(
8340 ProviderKind::Deepseek,
8341 Some(ProviderKind::Deepseek.as_str().into()),
8342 &config,
8343 None,
8344 );
8345 assert_eq!(picker.view, ProviderListView::Catalog);
8346
8347 assert!(matches!(
8348 picker.handle_key(key(KeyCode::Char('l'))),
8349 ViewAction::None
8350 ));
8351 assert_eq!(picker.view, ProviderListView::Local);
8352 assert_eq!(picker.selected_provider(), ProviderKind::Ollama);
8353 assert!(picker.filtered_rows().into_iter().all(|(_, row)| {
8354 row.provider.provider().credential_help().acquisition
8355 == codewhale_config::provider::CredentialAcquisition::LocalOptional
8356 }));
8357 }
8358
8359 #[test]
8360 fn onboarding_catalog_honors_typed_credentials_for_every_builtin_provider() {
8361 use codewhale_config::provider::CredentialAcquisition;
8362
8363 let _global_env = crate::test_support::lock_test_env();
8364 let home = tempfile::tempdir().expect("isolated provider catalog home");
8365 let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
8366 let _codewhale_home =
8367 EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
8368 let _codex_home = EnvVarGuard::set("CODEX_HOME", home.path().to_string_lossy().as_ref());
8369 let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
8370 let mut key_envs = ProviderKind::all()
8371 .iter()
8372 .flat_map(|provider| provider.provider().env_vars().iter().copied())
8373 .collect::<Vec<_>>();
8374 key_envs.sort_unstable();
8375 key_envs.dedup();
8376 let _missing_keys = key_envs
8377 .into_iter()
8378 .map(EnvVarGuard::remove)
8379 .collect::<Vec<_>>();
8380 let config = Config::default();
8381
8382 // Every provider the catalog actually lists. Hidden dual-wire/plan
8383 // variants share their vendor primary's row and credential metadata,
8384 // so `ProviderKind::all()` cannot be driven through the visible list.
8385 for provider in ProviderKind::all()
8386 .iter()
8387 .copied()
8388 .filter(|provider| *provider != ProviderKind::Custom)
8389 {
8390 let mut picker = ProviderPickerView::new_for_onboarding(
8391 ProviderKind::Deepseek,
8392 Some(provider.as_str().into()),
8393 &config,
8394 None,
8395 );
8396 assert_eq!(picker.selected_provider(), provider, "{provider:?}");
8397 assert_eq!(picker.stage, Stage::List, "{provider:?}");
8398
8399 let action = picker.handle_key(key(KeyCode::Enter));
8400 match provider.provider().credential_help().acquisition {
8401 CredentialAcquisition::ApiKey => {
8402 assert!(matches!(action, ViewAction::None), "{provider:?}");
8403 assert!(
8404 matches!(picker.stage, Stage::KeyEntry | Stage::StepfunBillingRoute),
8405 "{provider:?} entered {:?}",
8406 picker.stage
8407 );
8408 }
8409 CredentialAcquisition::ApiKeyOrOAuth => {
8410 assert_eq!(provider, ProviderKind::Xai, "{provider:?}");
8411 assert!(matches!(action, ViewAction::None), "{provider:?}");
8412 let choices = render_text(&picker, 80, 24);
8413 assert!(choices.contains("API key"), "{choices}");
8414 assert!(choices.contains("device OAuth"), "{choices}");
8415
8416 // Choice 1 is an ordinary API-key path. Text remains a key;
8417 // it is never reinterpreted as an OAuth bearer token.
8418 assert!(matches!(
8419 picker.handle_key(key(KeyCode::Char('1'))),
8420 ViewAction::None
8421 ));
8422 assert!(matches!(
8423 picker.handle_key(key(KeyCode::Enter)),
8424 ViewAction::None
8425 ));
8426 assert_eq!(picker.stage, Stage::KeyEntry);
8427 for ch in "violet-".chars() {
8428 picker.handle_key(key(KeyCode::Char(ch)));
8429 }
8430 assert!(picker.handle_paste("otter-key"));
8431 let key_text = "violet-otter-key";
8432 assert_eq!(picker.api_key_input, key_text);
8433 for (width, height) in [(80, 24), (120, 32)] {
8434 let rendered = render_text(&picker, width, height);
8435 assert!(!rendered.contains(key_text), "{width}x{height}: {rendered}");
8436 assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
8437 }
8438 assert!(matches!(
8439 picker.handle_key(key(KeyCode::Enter)),
8440 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
8441 identity,
8442 api_key,
8443 base_url: None,
8444 }) if identity.provider == ProviderKind::Xai && identity.persisted_id() == Some("xai") && api_key == key_text
8445 ));
8446
8447 // Choice 2 is the provider-native device flow and emits only
8448 // the request event; the picker never manufactures a token.
8449 let mut oauth = ProviderPickerView::new_for_onboarding(
8450 ProviderKind::Deepseek,
8451 Some(ProviderKind::Xai.as_str().into()),
8452 &config,
8453 None,
8454 );
8455 assert!(matches!(
8456 oauth.handle_key(key(KeyCode::Enter)),
8457 ViewAction::None
8458 ));
8459 assert!(matches!(
8460 oauth.handle_key(key(KeyCode::Char('2'))),
8461 ViewAction::None
8462 ));
8463 assert!(matches!(
8464 oauth.handle_key(key(KeyCode::Enter)),
8465 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
8466 ));
8467 }
8468 CredentialAcquisition::LocalOptional => assert!(matches!(
8469 action,
8470 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied {
8471 identity: applied,
8472 }) if applied.provider == provider && applied.persisted_id() == Some(provider.as_str())
8473 )),
8474 CredentialAcquisition::OAuth => {
8475 assert!(matches!(action, ViewAction::None), "{provider:?}");
8476 if provider == ProviderKind::OpenaiCodex {
8477 assert_eq!(picker.stage, Stage::ChatgptAuthChoice, "{provider:?}");
8478 assert!(!picker.handle_paste("fixture-oauth-paste"));
8479 assert!(
8480 picker.api_key_input.is_empty(),
8481 "{provider:?} must reject key paste"
8482 );
8483 assert!(matches!(
8484 picker.handle_key(key(KeyCode::Enter)),
8485 ViewAction::EmitAndClose(
8486 ViewEvent::ProviderPickerChatgptOAuthRequested
8487 )
8488 ));
8489 } else {
8490 assert_eq!(picker.stage, Stage::KeyEntry, "{provider:?}");
8491 assert!(picker.handle_paste("fixture-oauth-paste"));
8492 assert!(
8493 picker.api_key_input.is_empty(),
8494 "{provider:?} must reject key paste"
8495 );
8496 }
8497 }
8498 CredentialAcquisition::Configuration => {
8499 assert_eq!(provider, ProviderKind::Custom);
8500 assert!(matches!(action, ViewAction::None));
8501 assert_eq!(picker.stage, Stage::CustomForm);
8502 assert!(picker.api_key_input.is_empty());
8503 }
8504 }
8505 }
8506 }
8507
8508 #[test]
8509 fn credential_draft_is_masked_and_escape_drops_it_without_persistence() {
8510 let _global_env = crate::test_support::lock_test_env();
8511 let home = tempfile::tempdir().expect("isolated credential draft home");
8512 let _home = EnvVarGuard::set("HOME", home.path().to_string_lossy().as_ref());
8513 let _codewhale_home =
8514 EnvVarGuard::set("CODEWHALE_HOME", home.path().to_string_lossy().as_ref());
8515 let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
8516 let _openrouter_key = EnvVarGuard::remove("OPENROUTER_API_KEY");
8517 let config = Config::default();
8518 let draft = ["violet", "otter", "draft", "7361"].join("-");
8519 let mut picker = ProviderPickerView::new_for_missing_auth(
8520 ProviderKind::Deepseek,
8521 &(config).test_identity_for_kind(ProviderKind::Openrouter),
8522 &config,
8523 None,
8524 )
8525 .expect("OpenRouter key editor");
8526
8527 let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
8528 assert!(matches!(picker.handle_key(ctrl_v), ViewAction::None));
8529 assert!(
8530 picker.api_key_input.is_empty(),
8531 "shortcut must not type `v`"
8532 );
8533 let shifted_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT);
8534 assert!(matches!(picker.handle_key(shifted_v), ViewAction::None));
8535 assert_eq!(
8536 picker.api_key_input, "V",
8537 "shifted credential text is valid"
8538 );
8539 assert!(matches!(
8540 picker.handle_key(key(KeyCode::Backspace)),
8541 ViewAction::None
8542 ));
8543 assert!(picker.handle_paste(&draft));
8544 assert_eq!(picker.api_key_input, draft);
8545 for (width, height) in [(80, 24), (120, 32)] {
8546 let rendered = render_text(&picker, width, height);
8547 assert!(!rendered.contains(&draft), "{width}x{height}: {rendered}");
8548 assert!(rendered.contains('*'), "{width}x{height}: {rendered}");
8549 }
8550
8551 assert!(matches!(
8552 picker.handle_key(key(KeyCode::Esc)),
8553 ViewAction::None
8554 ));
8555 assert_eq!(picker.stage, Stage::List);
8556 assert!(picker.api_key_input.is_empty());
8557 assert_eq!(
8558 std::fs::read_dir(home.path())
8559 .expect("isolated home remains readable")
8560 .count(),
8561 0,
8562 "Esc must not create config or credential-backend files"
8563 );
8564 }
8565
8566 /// #4763: Escape backs out one stage at a time — key entry returns to the
8567 /// list, and only the list dismisses the picker.
8568 #[test]
8569 fn onboarding_escape_walks_key_entry_back_to_the_list_then_dismisses() {
8570 let _lock = crate::test_support::lock_test_env();
8571 let _anthropic_key = crate::test_support::EnvVarGuard::remove("ANTHROPIC_API_KEY");
8572 let config = Config::default();
8573 let mut picker = ProviderPickerView::new_for_onboarding(
8574 ProviderKind::Deepseek,
8575 Some(ProviderKind::Anthropic.as_str().into()),
8576 &config,
8577 None,
8578 );
8579 assert_eq!(picker.stage, Stage::List);
8580
8581 picker.enter_key_entry();
8582 assert_eq!(picker.stage, Stage::KeyEntry);
8583
8584 assert!(matches!(
8585 picker.handle_key(key(KeyCode::Esc)),
8586 ViewAction::None
8587 ));
8588 assert_eq!(
8589 picker.stage,
8590 Stage::List,
8591 "Escape from key entry returns to the provider list"
8592 );
8593
8594 assert!(
8595 matches!(
8596 picker.handle_key(key(KeyCode::Esc)),
8597 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
8598 ),
8599 "Escape from the list dismisses the picker"
8600 );
8601 }
8602
8603 #[test]
8604 fn setup_catalog_uses_setup_title() {
8605 let config = Config::default();
8606 let picker = ProviderPickerView::new_for_setup(ProviderKind::Deepseek, None, &config, None);
8607
8608 let rendered = render_text(&picker, 96, 20);
8609
8610 assert!(rendered.contains("Provider setup"));
8611 }
8612
8613 #[test]
8614 fn setup_catalog_key_entry_uses_setup_reopen_hint() {
8615 let config = Config::default();
8616 let picker = ProviderPickerView::new_for_setup(
8617 ProviderKind::Deepseek,
8618 Some(ProviderKind::Anthropic.as_str().into()),
8619 &config,
8620 None,
8621 );
8622
8623 let rendered = render_text(&picker, 96, 20);
8624
8625 assert!(rendered.contains("API key"));
8626 assert!(rendered.contains("/setup provider"));
8627 assert!(!rendered.contains("re-open /provider."));
8628 }
8629
8630 #[test]
8631 fn default_provider_picker_keeps_provider_reopen_hint() {
8632 let config = Config::default();
8633 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8634 move_to_provider(&mut picker, ProviderKind::Anthropic);
8635 picker.handle_key(key(KeyCode::Enter));
8636
8637 let rendered = render_text(&picker, 96, 20);
8638
8639 assert!(rendered.contains("API key"));
8640 assert!(rendered.contains("re-open /provider."));
8641 assert!(!rendered.contains("/setup provider"));
8642 }
8643
8644 #[test]
8645 fn setup_catalog_focuses_configured_provider_without_rekeying() {
8646 let config = Config {
8647 providers: Some(crate::config::ProvidersConfig {
8648 openai: crate::config::ProviderConfig {
8649 api_key: Some("openai-key".to_string()),
8650 ..Default::default()
8651 },
8652 ..Default::default()
8653 }),
8654 ..Config::default()
8655 };
8656 let picker = ProviderPickerView::new_for_setup(
8657 ProviderKind::Deepseek,
8658 Some(ProviderKind::Openai.as_str().into()),
8659 &config,
8660 None,
8661 );
8662
8663 assert_eq!(picker.view, ProviderListView::Catalog);
8664 assert_eq!(picker.stage, Stage::List);
8665 assert_eq!(picker.selected_provider(), ProviderKind::Openai);
8666 }
8667
8668 #[test]
8669 fn new_for_key_entry_with_error_opens_prompt_and_renders_reason() {
8670 let config = Config::default();
8671 let picker = ProviderPickerView::new_for_key_entry_with_error(
8672 ProviderKind::Deepseek,
8673 &(config).test_identity_for_kind(ProviderKind::Openrouter),
8674 &config,
8675 None,
8676 "HTTP 401: unauthorized".to_string(),
8677 )
8678 .expect("OpenRouter has a picker row");
8679
8680 assert_eq!(picker.stage, Stage::KeyEntry);
8681 assert_eq!(picker.selected_provider(), ProviderKind::Openrouter);
8682 let rendered = render_text(&picker, 90, 14);
8683 // The caller supplies the plain sentence; the picker shows it as is.
8684 assert!(rendered.contains("HTTP 401: unauthorized"), "{rendered}");
8685 assert!(!rendered.contains("Verification failed"), "{rendered}");
8686 }
8687
8688 #[test]
8689 fn new_for_model_pick_after_validation_opens_model_stage() {
8690 let config = Config::default();
8691 let picker = ProviderPickerView::new_for_model_pick_after_validation(
8692 ProviderKind::Deepseek,
8693 &(config).test_identity_for_kind(ProviderKind::Openrouter),
8694 &config,
8695 None,
8696 "sk-validated".to_string(),
8697 None,
8698 )
8699 .expect("OpenRouter has a picker row");
8700
8701 assert_eq!(picker.stage, Stage::ModelPick);
8702 assert_eq!(picker.selected_provider(), ProviderKind::Openrouter);
8703 assert_eq!(picker.pending_api_key.as_deref(), Some("sk-validated"));
8704 assert!(!picker.model_options.is_empty());
8705 assert!(picker.selected_model.is_some());
8706 }
8707
8708 #[test]
8709 fn model_pick_enter_advances_to_confirm_and_confirm_emits_setup() {
8710 let config = Config::default();
8711 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
8712 ProviderKind::Deepseek,
8713 &(config).test_identity_for_kind(ProviderKind::Openrouter),
8714 &config,
8715 None,
8716 "sk-validated".to_string(),
8717 None,
8718 )
8719 .expect("OpenRouter has a picker row");
8720
8721 assert_eq!(picker.stage, Stage::ModelPick);
8722 let action = picker.handle_key(key(KeyCode::Enter));
8723 assert!(matches!(action, ViewAction::None));
8724 assert_eq!(picker.stage, Stage::Confirm);
8725
8726 let selected_model = picker
8727 .selected_model
8728 .clone()
8729 .expect("model selected on confirm");
8730 let action = picker.handle_key(key(KeyCode::Enter));
8731 match action {
8732 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
8733 identity,
8734 api_key,
8735 model,
8736 ..
8737 }) => {
8738 let provider = identity.provider;
8739 let provider_id = identity.persisted_id();
8740 assert_eq!(provider, ProviderKind::Openrouter);
8741 assert_eq!(provider_id, Some(provider.as_str()));
8742 assert_eq!(api_key, "sk-validated");
8743 assert_eq!(model, selected_model);
8744 }
8745 other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
8746 }
8747 }
8748
8749 #[test]
8750 fn exact_kimi_code_setup_asks_for_plan_and_emits_selected_context_window() {
8751 let config = Config {
8752 providers: Some(crate::config::ProvidersConfig {
8753 moonshot: crate::config::ProviderConfig {
8754 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
8755 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
8756 ..Default::default()
8757 },
8758 ..Default::default()
8759 }),
8760 ..Default::default()
8761 };
8762 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
8763 ProviderKind::Deepseek,
8764 &(config).test_identity_for_kind(ProviderKind::Moonshot),
8765 &config,
8766 None,
8767 "sk-kimi-plan".to_string(),
8768 None,
8769 )
8770 .expect("Moonshot has a picker row");
8771
8772 assert_eq!(picker.stage, Stage::ModelPick);
8773 assert!(matches!(
8774 picker.handle_key(key(KeyCode::Enter)),
8775 ViewAction::None
8776 ));
8777 assert_eq!(picker.stage, Stage::PlanTier);
8778 assert!(matches!(
8779 picker.handle_key(key(KeyCode::Char('2'))),
8780 ViewAction::None
8781 ));
8782 assert!(matches!(
8783 picker.handle_key(key(KeyCode::Enter)),
8784 ViewAction::None
8785 ));
8786 assert_eq!(picker.stage, Stage::Confirm);
8787 match picker.handle_key(key(KeyCode::Enter)) {
8788 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
8789 context_window,
8790 model,
8791 ..
8792 }) => {
8793 assert_eq!(model, crate::config::KIMI_CODE_K3_MODEL);
8794 assert_eq!(context_window, Some(1_048_576));
8795 }
8796 other => panic!("expected Kimi Code setup confirmation, got {other:?}"),
8797 }
8798 }
8799
8800 #[test]
8801 fn model_pick_and_confirm_esc_backs_out_without_emitting() {
8802 let config = Config::default();
8803 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
8804 ProviderKind::Deepseek,
8805 &(config).test_identity_for_kind(ProviderKind::Openrouter),
8806 &config,
8807 None,
8808 "sk-validated".to_string(),
8809 None,
8810 )
8811 .expect("OpenRouter has a picker row");
8812
8813 picker.handle_key(key(KeyCode::Enter));
8814 assert_eq!(picker.stage, Stage::Confirm);
8815 assert!(matches!(
8816 picker.handle_key(key(KeyCode::Esc)),
8817 ViewAction::None
8818 ));
8819 assert_eq!(picker.stage, Stage::ModelPick);
8820
8821 assert!(matches!(
8822 picker.handle_key(key(KeyCode::Esc)),
8823 ViewAction::None
8824 ));
8825 assert_eq!(picker.stage, Stage::KeyEntry);
8826 assert_eq!(picker.api_key_input, "sk-validated");
8827 assert!(picker.pending_api_key.is_some());
8828 }
8829
8830 fn stepfun_config(base_url: Option<&str>) -> Config {
8831 Config {
8832 providers: Some(crate::config::ProvidersConfig {
8833 stepfun: crate::config::ProviderConfig {
8834 base_url: base_url.map(str::to_string),
8835 ..Default::default()
8836 },
8837 ..Default::default()
8838 }),
8839 ..Default::default()
8840 }
8841 }
8842
8843 /// #4526: StepFun's two billing tracks are two endpoints. Setup asks which
8844 /// one the key belongs to, and the choice reaches key entry as a pending —
8845 /// not yet persisted — endpoint.
8846 #[test]
8847 fn stepfun_setup_asks_for_billing_route_before_key_entry() {
8848 let config = stepfun_config(None);
8849 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8850 move_to_provider(&mut picker, ProviderKind::Stepfun);
8851
8852 assert!(matches!(
8853 picker.handle_key(key(KeyCode::Char('r'))),
8854 ViewAction::None
8855 ));
8856 assert_eq!(picker.stage, Stage::StepfunBillingRoute);
8857 assert_eq!(
8858 picker.stepfun_billing_route,
8859 StepfunBillingRoute::PayAsYouGo
8860 );
8861
8862 // The endpoints are the whole difference between the two tracks, so
8863 // both have to be legible at the narrow terminal size too.
8864 for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
8865 let rendered = render_text(&picker, w, h);
8866 assert!(
8867 rendered.contains(crate::config::DEFAULT_STEPFUN_BASE_URL)
8868 && rendered.contains(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL),
8869 "{w}x{h} must show both StepFun endpoints:\n{rendered}"
8870 );
8871 for (idx, line) in rendered.lines().enumerate() {
8872 assert!(
8873 crate::tui::ui_text::text_display_width(line) <= w as usize,
8874 "{w}x{h} billing-route line {idx} overflows: {line:?}"
8875 );
8876 }
8877 }
8878
8879 assert!(matches!(
8880 picker.handle_key(key(KeyCode::Char('2'))),
8881 ViewAction::None
8882 ));
8883 assert!(matches!(
8884 picker.handle_key(key(KeyCode::Enter)),
8885 ViewAction::None
8886 ));
8887 assert_eq!(picker.stage, Stage::KeyEntry);
8888 assert_eq!(
8889 picker.pending_base_url.as_deref(),
8890 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
8891 );
8892 }
8893
8894 /// The chosen endpoint rides on the key-submit event so the live check in
8895 /// `ui.rs` probes the Step Plan route, not the pay-as-you-go default.
8896 #[test]
8897 fn stepfun_plan_choice_travels_with_the_key_for_validation() {
8898 let config = stepfun_config(None);
8899 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8900 move_to_provider(&mut picker, ProviderKind::Stepfun);
8901 picker.handle_key(key(KeyCode::Char('r')));
8902 picker.handle_key(key(KeyCode::Char('2')));
8903 picker.handle_key(key(KeyCode::Enter));
8904 for c in "step-plan-key".chars() {
8905 picker.handle_key(key(KeyCode::Char(c)));
8906 }
8907
8908 match picker.handle_key(key(KeyCode::Enter)) {
8909 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
8910 identity,
8911 api_key,
8912 base_url,
8913 ..
8914 }) => {
8915 let provider = identity.provider;
8916 assert_eq!(provider, ProviderKind::Stepfun);
8917 assert_eq!(api_key, "step-plan-key");
8918 assert_eq!(
8919 base_url.as_deref(),
8920 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
8921 );
8922 }
8923 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
8924 }
8925 }
8926
8927 /// Confirm carries exactly the validated endpoint, and nothing else about
8928 /// the route, so the handler writes only `[providers.stepfun] base_url`.
8929 #[test]
8930 fn stepfun_confirm_emits_only_the_validated_endpoint() {
8931 let config = stepfun_config(None);
8932 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
8933 ProviderKind::Deepseek,
8934 &(config).test_identity_for_kind(ProviderKind::Stepfun),
8935 &config,
8936 None,
8937 "step-plan-key".to_string(),
8938 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL.to_string()),
8939 )
8940 .expect("StepFun has a picker row");
8941
8942 assert_eq!(picker.stage, Stage::ModelPick);
8943 picker.handle_key(key(KeyCode::Enter));
8944 assert_eq!(picker.stage, Stage::Confirm);
8945 match picker.handle_key(key(KeyCode::Enter)) {
8946 ViewAction::EmitAndClose(ViewEvent::ProviderPickerSetupConfirmed {
8947 identity,
8948 base_url,
8949 context_window,
8950 ..
8951 }) => {
8952 let provider = identity.provider;
8953 assert_eq!(provider, ProviderKind::Stepfun);
8954 assert_eq!(
8955 base_url.as_deref(),
8956 Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL)
8957 );
8958 assert_eq!(context_window, None);
8959 }
8960 other => panic!("expected ProviderPickerSetupConfirmed, got {other:?}"),
8961 }
8962 }
8963
8964 /// A hand-configured StepFun endpoint is a deliberate choice. The wizard
8965 /// skips the billing-route stage entirely and emits no endpoint, so the
8966 /// custom value is never silently rewritten (#4526).
8967 #[test]
8968 fn stepfun_custom_base_url_survives_the_wizard_untouched() {
8969 let custom = "https://stepfun.internal.example/v1";
8970 let config = stepfun_config(Some(custom));
8971 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8972 move_to_provider(&mut picker, ProviderKind::Stepfun);
8973 assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
8974
8975 picker.handle_key(key(KeyCode::Char('r')));
8976 assert_eq!(picker.stage, Stage::KeyEntry);
8977 assert_eq!(picker.pending_base_url, None);
8978 assert_eq!(picker.rows[picker.selected_idx].base_url, custom);
8979
8980 for c in "custom-key".chars() {
8981 picker.handle_key(key(KeyCode::Char(c)));
8982 }
8983 match picker.handle_key(key(KeyCode::Enter)) {
8984 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
8985 base_url, ..
8986 }) => assert_eq!(base_url, None, "custom endpoint must not be rewritten"),
8987 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
8988 }
8989 }
8990
8991 /// A StepFun route already on Step Plan re-opens preselected there rather
8992 /// than defaulting the user back onto pay-as-you-go.
8993 #[test]
8994 fn stepfun_plan_route_reopens_preselected() {
8995 let config = stepfun_config(Some(crate::config::DEFAULT_STEPFUN_PLAN_BASE_URL));
8996 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
8997 move_to_provider(&mut picker, ProviderKind::Stepfun);
8998 picker.handle_key(key(KeyCode::Char('r')));
8999
9000 assert_eq!(picker.stage, Stage::StepfunBillingRoute);
9001 assert_eq!(picker.stepfun_billing_route, StepfunBillingRoute::StepPlan);
9002 }
9003
9004 /// #4526: OpenCode Go (subscription allowance) and OpenCode Zen
9005 /// (pay-as-you-go) are separate billing tracks and must not present as the
9006 /// same generic meter. Slice D: the distinction now lives on the
9007 /// per-model cost label, not on a provider row.
9008 #[test]
9009 fn opencode_go_and_zen_read_as_distinct_billing_tracks() {
9010 let token = PricingSku::Token {
9011 input_per_mtok: Some(1.0),
9012 output_per_mtok: Some(2.0),
9013 };
9014 let go = model_cost_label_for_pricing(ProviderKind::OpencodeGo, Some(&token));
9015 let zen = model_cost_label_for_pricing(ProviderKind::OpencodeZen, Some(&token));
9016 assert_ne!(go, zen);
9017 assert_eq!(go, "plan", "Go label was {go:?}");
9018 assert_eq!(zen, "$1.00/$2.00 per 1M", "Zen label was {zen:?}");
9019 assert_ne!(
9020 go,
9021 model_cost_label_for_pricing(ProviderKind::Openrouter, None)
9022 );
9023
9024 // Go never reports catalog token prices: its allowance is not spend.
9025 assert_eq!(model_cost_label(ProviderKind::OpencodeGo, "some-model"), go);
9026 }
9027
9028 /// Slice D: per-model $/mtok in/out from the catalog, with honest
9029 /// non-token fallbacks and never a fabricated rate.
9030 #[test]
9031 fn model_cost_label_spells_out_mtok_in_and_out() {
9032 let token = PricingSku::Token {
9033 input_per_mtok: Some(1.5),
9034 output_per_mtok: Some(6.0),
9035 };
9036 assert_eq!(
9037 model_cost_label_for_pricing(ProviderKind::Deepseek, Some(&token)),
9038 "$1.50/$6.00 per 1M"
9039 );
9040 // Partial token pricing never fabricates the missing leg.
9041 let partial = PricingSku::Token {
9042 input_per_mtok: Some(1.5),
9043 output_per_mtok: None,
9044 };
9045 assert_eq!(
9046 model_cost_label_for_pricing(ProviderKind::Deepseek, Some(&partial)),
9047 "token-priced"
9048 );
9049 assert_eq!(
9050 model_cost_label_for_pricing(
9051 ProviderKind::Deepseek,
9052 Some(&PricingSku::SubscriptionQuota {
9053 used_pct: None,
9054 resets_at: None,
9055 }),
9056 ),
9057 "plan"
9058 );
9059 assert_eq!(
9060 model_cost_label_for_pricing(
9061 ProviderKind::Deepseek,
9062 Some(&PricingSku::AccountCredits { balance: None }),
9063 ),
9064 "credits"
9065 );
9066 assert_eq!(
9067 model_cost_label_for_pricing(
9068 ProviderKind::Deepseek,
9069 Some(&PricingSku::LocalOrNotApplicable),
9070 ),
9071 "local"
9072 );
9073 // Unknown pricing falls back honestly per provider posture.
9074 assert_eq!(
9075 model_cost_label_for_pricing(ProviderKind::Ollama, None),
9076 "local"
9077 );
9078 assert_eq!(
9079 model_cost_label_for_pricing(ProviderKind::OpenaiCodex, None),
9080 "oauth quota"
9081 );
9082 assert_eq!(
9083 model_cost_label_for_pricing(ProviderKind::Deepseek, None),
9084 "price unknown"
9085 );
9086 }
9087
9088 /// Slice D two-pane picker: the models pane leads with the default route
9089 /// and every row carries a non-empty cost label.
9090 #[test]
9091 fn provider_pane_models_lead_with_default_route() {
9092 let config = Config::default();
9093 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9094 let row = picker
9095 .rows
9096 .iter()
9097 .find(|row| row.provider == ProviderKind::Deepseek)
9098 .expect("DeepSeek has a picker row");
9099 let models = provider_pane_models(&config, row, 8);
9100 assert!(!models.is_empty(), "models pane must never render empty");
9101 assert!(models.len() <= 8);
9102 let (first, _, first_default) = &models[0];
9103 assert!(
9104 *first_default,
9105 "default route model must sort first, got {first:?}"
9106 );
9107 assert!(
9108 first.eq_ignore_ascii_case(&row.default_route.logical_model)
9109 || first.eq_ignore_ascii_case(&row.default_route.wire_model),
9110 "first pane model {first:?} is not the default route"
9111 );
9112 for (model, price, _) in &models {
9113 assert!(!price.trim().is_empty(), "model {model:?} needs a price");
9114 }
9115 }
9116
9117 /// Slice D: the list stage pairs the provider strip with a models pane —
9118 /// no provider-level cost, per-model $/mtok beside it.
9119 #[test]
9120 fn list_stage_pairs_provider_strip_with_priced_models_pane() {
9121 let config = Config::default();
9122 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9123 let rendered = render_text(&picker, 124, 24);
9124 assert!(rendered.contains("Models · price in/out"), "{rendered}");
9125 assert!(rendered.contains("(default)"), "{rendered}");
9126 assert!(!rendered.contains("cost:"), "{rendered}");
9127 assert!(!rendered.contains("Usage:"), "{rendered}");
9128 }
9129
9130 /// Slice D: provider-strip rows are clickable and hover visibly without
9131 /// disturbing the keyboard selection.
9132 #[test]
9133 fn provider_strip_rows_are_clickable_and_hoverable() {
9134 let config = Config::default();
9135 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9136 move_to_provider(&mut picker, ProviderKind::Ollama);
9137 // Render first so this frame's hitboxes exist.
9138 let _ = render_text(&picker, 120, 24);
9139 assert!(
9140 !picker.list_row_hitboxes.borrow().is_empty(),
9141 "list rows must record hitboxes"
9142 );
9143 let ollama_idx = picker
9144 .rows
9145 .iter()
9146 .position(|row| row.provider == ProviderKind::Ollama)
9147 .expect("Ollama has a picker row");
9148 let ollama_hit = picker
9149 .list_row_hitboxes
9150 .borrow()
9151 .iter()
9152 .find(|(_, idx)| *idx == ollama_idx)
9153 .map(|(rect, _)| *rect)
9154 .expect("Ollama row must be visible");
9155 let other_hit = picker
9156 .list_row_hitboxes
9157 .borrow()
9158 .iter()
9159 .find(|(_, idx)| *idx != ollama_idx)
9160 .map(|(rect, idx)| (*rect, *idx))
9161 .expect("a second visible row is needed");
9162 // Hover tracks the pointer and leaves the selection alone.
9163 let selected_before = picker.selected_idx;
9164 picker.handle_mouse(MouseEvent {
9165 kind: MouseEventKind::Moved,
9166 column: other_hit.0.x,
9167 row: other_hit.0.y,
9168 modifiers: KeyModifiers::NONE,
9169 });
9170 assert_eq!(picker.hovered_list_idx, Some(other_hit.1));
9171 assert_eq!(picker.selected_idx, selected_before);
9172 // Moving off every row clears the hover.
9173 picker.handle_mouse(MouseEvent {
9174 kind: MouseEventKind::Moved,
9175 column: 0,
9176 row: 0,
9177 modifiers: KeyModifiers::NONE,
9178 });
9179 // Single click selects; a second click activates like Enter.
9180 // (Ollama needs no key, so activation applies immediately.)
9181 let click = MouseEvent {
9182 kind: MouseEventKind::Down(MouseButton::Left),
9183 column: ollama_hit.x,
9184 row: ollama_hit.y,
9185 modifiers: KeyModifiers::NONE,
9186 };
9187 assert!(matches!(picker.handle_mouse(click), ViewAction::None));
9188 assert_eq!(picker.selected_idx, ollama_idx);
9189 match picker.handle_mouse(click) {
9190 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { identity, .. }) => {
9191 let provider = identity.provider;
9192 assert_eq!(provider, ProviderKind::Ollama);
9193 }
9194 other => panic!("double-click must apply Ollama, got {other:?}"),
9195 }
9196 }
9197
9198 /// Slice D: model-pick rows carry per-model cost and are clickable.
9199 #[test]
9200 fn model_pick_rows_show_per_model_cost_and_click_selects() {
9201 let _env = crate::test_support::lock_test_env();
9202 let home = tempfile::tempdir().unwrap();
9203 // Select the physical test home. The credential store deliberately
9204 // refuses linked ancestors, including macOS's /var temporary alias.
9205 let home_path = home.path().canonicalize().unwrap();
9206 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home_path);
9207 let mut config = Config::default();
9208 crate::oauth::install_test_chatgpt_registration(&mut config).unwrap();
9209 crate::codex_model_cache::install_test_chatgpt_roster(&config, &["official-model"])
9210 .unwrap();
9211 // ChatGPT plan billing comes from the validated selected route.
9212 let mut picker = ProviderPickerView::new_for_model_pick_after_validation(
9213 ProviderKind::Deepseek,
9214 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
9215 &config,
9216 None,
9217 "[REDACTED]".to_string(),
9218 None,
9219 )
9220 .expect("Codex has a picker row");
9221 assert_eq!(picker.stage, Stage::ModelPick);
9222 let rendered = render_text(&picker, 100, 24);
9223 assert!(rendered.contains("ChatGPT plan allowance"), "{rendered}");
9224 assert!(
9225 !picker.model_row_hitboxes.borrow().is_empty(),
9226 "model rows must record hitboxes"
9227 );
9228 let target = picker.model_row_hitboxes.borrow()[0];
9229 picker.handle_mouse(MouseEvent {
9230 kind: MouseEventKind::Down(MouseButton::Left),
9231 column: target.0.x,
9232 row: target.0.y,
9233 modifiers: KeyModifiers::NONE,
9234 });
9235 assert_eq!(picker.model_selected_idx, target.1);
9236 }
9237
9238 /// Slice D explicit-consent gate: Gate 1 chooses (clickable), Gate 2
9239 /// discloses, and a click alone never grants anything.
9240 #[test]
9241 fn external_consent_gate_chooses_then_discloses() {
9242 let config = Config::default();
9243 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9244 move_to_provider(&mut picker, ProviderKind::Xai);
9245 picker.handle_key(key(KeyCode::Char('e')));
9246 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
9247 let rendered = render_text(&picker, 100, 24);
9248 assert!(rendered.contains("Gate 1 of 2"), "{rendered}");
9249 assert_eq!(picker.consent_row_hitboxes.borrow().len(), 3);
9250 // Click the read-only option: chosen, not granted.
9251 let readonly_hit = picker.consent_row_hitboxes.borrow()[1];
9252 picker.handle_mouse(MouseEvent {
9253 kind: MouseEventKind::Down(MouseButton::Left),
9254 column: readonly_hit.0.x,
9255 row: readonly_hit.0.y,
9256 modifiers: KeyModifiers::NONE,
9257 });
9258 assert_eq!(
9259 picker.external_consent_choice,
9260 ExternalConsentChoice::ReadOnly
9261 );
9262 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
9263 // Enter advances to the Gate 2 disclosure.
9264 picker.handle_key(key(KeyCode::Enter));
9265 assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
9266 let confirm = render_text(&picker, 100, 24);
9267 assert!(confirm.contains("Gate 2 of 2"), "{confirm}");
9268 }
9269
9270 #[test]
9271 fn guided_flow_stages_render_at_80x24_and_120x32() {
9272 let config = Config::default();
9273 let model_pick = ProviderPickerView::new_for_model_pick_after_validation(
9274 ProviderKind::Deepseek,
9275 &(config).test_identity_for_kind(ProviderKind::Openrouter),
9276 &config,
9277 None,
9278 "sk-validated-key".to_string(),
9279 None,
9280 )
9281 .expect("OpenRouter has a picker row");
9282 let mut confirm = ProviderPickerView::new_for_model_pick_after_validation(
9283 ProviderKind::Deepseek,
9284 &(config).test_identity_for_kind(ProviderKind::Openrouter),
9285 &config,
9286 None,
9287 "sk-validated-key".to_string(),
9288 None,
9289 )
9290 .expect("OpenRouter has a picker row");
9291 confirm.handle_key(key(KeyCode::Enter));
9292 assert_eq!(confirm.stage, Stage::Confirm);
9293
9294 for (w, h) in [(80u16, 24u16), (120u16, 32u16)] {
9295 let model_text = render_text(&model_pick, w, h);
9296 assert!(
9297 model_text.contains("Default model") || model_text.contains("default model"),
9298 "{w}x{h} model pick missing title:\n{model_text}"
9299 );
9300 assert!(
9301 model_text.contains("continue") || model_text.contains("Enter"),
9302 "{w}x{h} model pick missing continue affordance:\n{model_text}"
9303 );
9304 for (idx, line) in model_text.lines().enumerate() {
9305 assert!(
9306 crate::tui::ui_text::text_display_width(line) <= w as usize,
9307 "{w}x{h} model pick line {idx} overflows: {line:?}"
9308 );
9309 }
9310
9311 let confirm_text = render_text(&confirm, w, h);
9312 assert!(
9313 confirm_text.contains("Confirm"),
9314 "{w}x{h} confirm missing title:\n{confirm_text}"
9315 );
9316 assert!(
9317 confirm_text.contains("Provider:") || confirm_text.contains("OpenRouter"),
9318 "{w}x{h} confirm missing provider summary:\n{confirm_text}"
9319 );
9320 assert!(
9321 confirm_text.contains("Model:") || confirm_text.contains("model"),
9322 "{w}x{h} confirm missing model summary:\n{confirm_text}"
9323 );
9324 // Masked key only — never the raw secret.
9325 assert!(
9326 !confirm_text.contains("sk-validated-key"),
9327 "{w}x{h} confirm leaked raw key:\n{confirm_text}"
9328 );
9329 for (idx, line) in confirm_text.lines().enumerate() {
9330 assert!(
9331 crate::tui::ui_text::text_display_width(line) <= w as usize,
9332 "{w}x{h} confirm line {idx} overflows: {line:?}"
9333 );
9334 }
9335 }
9336 }
9337
9338 #[test]
9339 fn configured_provider_can_reenter_key_entry_with_r() {
9340 let config = Config {
9341 providers: Some(crate::config::ProvidersConfig {
9342 xiaomi_mimo: crate::config::ProviderConfig {
9343 api_key: Some("mimo-key".to_string()),
9344 ..Default::default()
9345 },
9346 ..Default::default()
9347 }),
9348 ..Config::default()
9349 };
9350 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9351 move_to_provider(&mut picker, ProviderKind::XiaomiMimo);
9352
9353 let action = picker.handle_key(key(KeyCode::Char('r')));
9354
9355 assert!(matches!(action, ViewAction::None));
9356 assert_eq!(picker.stage, Stage::KeyEntry);
9357 assert!(picker.api_key_input.is_empty());
9358 }
9359
9360 #[test]
9361 fn configured_api_key_editors_acknowledge_saved_credentials_across_providers() {
9362 for (provider, config, secret) in [
9363 (
9364 ProviderKind::Zai,
9365 Config {
9366 providers: Some(crate::config::ProvidersConfig {
9367 zai: crate::config::ProviderConfig {
9368 api_key: Some("stored-zai-key".to_string()),
9369 ..Default::default()
9370 },
9371 ..Default::default()
9372 }),
9373 ..Config::default()
9374 },
9375 "stored-zai-key",
9376 ),
9377 (
9378 ProviderKind::Openrouter,
9379 Config {
9380 providers: Some(crate::config::ProvidersConfig {
9381 openrouter: crate::config::ProviderConfig {
9382 api_key: Some("stored-openrouter-key".to_string()),
9383 ..Default::default()
9384 },
9385 ..Default::default()
9386 }),
9387 ..Config::default()
9388 },
9389 "stored-openrouter-key",
9390 ),
9391 ] {
9392 let mut picker = ProviderPickerView::new(provider, &config);
9393 move_to_provider(&mut picker, provider);
9394 picker.handle_key(key(KeyCode::Char('r')));
9395
9396 let rendered = render_text(&picker, 100, 20);
9397
9398 assert!(
9399 rendered.contains("A key is already set up"),
9400 "{provider:?}:\n{rendered}"
9401 );
9402 assert!(rendered.contains("stored credential"), "{rendered}");
9403 assert!(rendered.contains("replace the key"), "{rendered}");
9404 assert!(rendered.contains("keep current key"), "{rendered}");
9405 assert!(!rendered.contains("paste key here"), "{rendered}");
9406 assert!(!rendered.contains(secret), "{rendered}");
9407 }
9408 }
9409
9410 #[test]
9411 fn ctrl_r_does_not_trigger_key_entry() {
9412 let config = Config::default();
9413 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9414
9415 let action = picker.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
9416
9417 assert!(matches!(action, ViewAction::None));
9418 assert_eq!(picker.stage, Stage::List);
9419 }
9420
9421 #[test]
9422 fn configured_provider_footer_mentions_edit_key() {
9423 let config = Config {
9424 ..Config::default()
9425 }
9426 .with_legacy_root(Some("existing-deepseek-key".to_string()), None);
9427 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9428
9429 let rendered = render_text(&picker, 80, 14);
9430
9431 assert!(rendered.contains("Enter"), "rendered: {rendered}");
9432 assert!(rendered.contains("apply"));
9433 assert!(rendered.contains("edit key"));
9434 }
9435
9436 #[test]
9437 fn key_entry_enter_submits_after_typing() {
9438 let config = Config::default();
9439 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9440 // Navigate to Novita and trigger key entry.
9441 move_to_provider(&mut picker, ProviderKind::Novita);
9442 picker.handle_key(key(KeyCode::Enter));
9443 assert_eq!(picker.stage, Stage::KeyEntry);
9444 for c in "novita-key".chars() {
9445 picker.handle_key(key(KeyCode::Char(c)));
9446 }
9447 let action = picker.handle_key(key(KeyCode::Enter));
9448 match action {
9449 ViewAction::EmitAndClose(ViewEvent::ProviderPickerApiKeySubmitted {
9450 identity,
9451 api_key,
9452 base_url,
9453 }) => {
9454 let provider = identity.provider;
9455 let provider_id = identity.persisted_id();
9456 assert_eq!(provider, ProviderKind::Novita);
9457 assert_eq!(provider_id, Some(provider.as_str()));
9458 assert_eq!(api_key, "novita-key");
9459 assert_eq!(base_url, None);
9460 }
9461 other => panic!("expected ProviderPickerApiKeySubmitted, got {other:?}"),
9462 }
9463 }
9464
9465 #[test]
9466 fn openai_codex_key_entry_is_oauth_only() {
9467 let _environment = crate::test_support::lock_test_env();
9468 // This is a disclosure fixture, independent of the developer's home
9469 // path length or actual Codex credentials. No file is read here.
9470 let _path = crate::test_support::EnvVarGuard::set(
9471 "OPENAI_CODEX_AUTH_FILE",
9472 "/fixture/codex-auth.json",
9473 );
9474 let config = Config::default();
9475 let mut picker = ProviderPickerView::new_for_missing_auth(
9476 ProviderKind::Deepseek,
9477 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
9478 &config,
9479 None,
9480 )
9481 .expect("OpenAI Codex has a picker row");
9482 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9483
9484 let rendered = render_text(&picker, 96, 20);
9485 assert!(rendered.contains("Sign in with ChatGPT"), "{rendered}");
9486 assert!(rendered.contains("subscription"), "{rendered}");
9487 assert!(rendered.contains("billing"), "{rendered}");
9488 assert!(!rendered.contains("Import Codex CLI"), "{rendered}");
9489 assert!(!rendered.contains("save & switch"));
9490 assert!(!rendered.contains("(paste key here)"));
9491 assert!(!rendered.contains("Credentials:"));
9492
9493 assert!(!picker.handle_paste("codex-token"));
9494 assert!(picker.api_key_input.is_empty());
9495 assert!(matches!(
9496 picker.handle_key(key(KeyCode::Enter)),
9497 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
9498 ));
9499
9500 let mut picker = ProviderPickerView::new_for_missing_auth(
9501 ProviderKind::Deepseek,
9502 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
9503 &config,
9504 None,
9505 )
9506 .expect("OpenAI Codex has a picker row");
9507 // The old numeric import choice cannot open external credential setup.
9508 picker.handle_key(key(KeyCode::Char('2')));
9509 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9510 assert!(matches!(
9511 picker.handle_key(key(KeyCode::Enter)),
9512 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
9513 ));
9514 // Exercise the legacy disclosure adapter explicitly as a diagnostic.
9515 picker.enter_external_consent_choice();
9516 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
9517 let choices = render_text(&picker, 100, 20);
9518 assert!(choices.contains("Disabled (default)"), "{choices}");
9519 assert!(
9520 choices.contains("Use external CLI credentials (read-only)"),
9521 "{choices}"
9522 );
9523 assert!(choices.contains("Managed (unavailable)"), "{choices}");
9524 // #5772: the choice stage must not disclose the candidate path; only
9525 // the confirmation step may.
9526 assert!(!choices.contains("Exact resolved path:"), "{choices}");
9527
9528 picker.handle_key(key(KeyCode::Char('2')));
9529 picker.handle_key(key(KeyCode::Enter));
9530 assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
9531 let confirm = render_text(&picker, 120, 22);
9532 assert!(confirm.contains("Owning CLI: Codex CLI"), "{confirm}");
9533 assert!(confirm.contains("Exact resolved path:"), "{confirm}");
9534 assert!(confirm.contains("Route:"), "{confirm}");
9535 assert!(confirm.contains("local device only"), "{confirm}");
9536 assert!(confirm.contains("billing owner"), "{confirm}");
9537 assert!(confirm.contains("no refresh, identity-provider or discovery requests"));
9538 assert!(confirm.contains("normal requests to the selected provider"));
9539 assert!(
9540 confirm.contains("clears only Codewhale's consent record"),
9541 "{confirm}"
9542 );
9543 assert!(confirm.contains("external-revoke --provider openai-codex"));
9544 // Confirmation is escapable without any event being emitted.
9545 assert!(matches!(
9546 picker.handle_key(key(KeyCode::Esc)),
9547 ViewAction::None
9548 ));
9549 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
9550 picker.handle_key(key(KeyCode::Char('2')));
9551 picker.handle_key(key(KeyCode::Enter));
9552 assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
9553 assert!(matches!(
9554 picker.handle_key(key(KeyCode::Enter)),
9555 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentConfirmed {
9556 provider: ProviderKind::OpenaiCodex,
9557 consent_provider: codewhale_config::ProviderKind::OpenaiCodex,
9558 source: codewhale_config::ExternalCredentialSource::CodexCli,
9559 ..
9560 })
9561 ));
9562 }
9563
9564 #[test]
9565 fn chatgpt_list_and_locked_key_entry_request_only_owned_sign_in() {
9566 let _env = crate::test_support::lock_test_env();
9567 let home = tempfile::tempdir().unwrap();
9568 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
9569 let _token = crate::test_support::EnvVarGuard::set("CODEX_ACCESS_TOKEN", "legacy-token");
9570 let config = Config::default();
9571 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9572 move_to_provider(&mut picker, ProviderKind::OpenaiCodex);
9573 // An old consent projection or missing model catalog cannot bypass
9574 // the new grant or prevent the user from reaching browser sign-in.
9575 picker.rows[picker.selected_idx].credential_state = CredentialState::ExternalConsent;
9576 picker.rows[picker.selected_idx].route_ok = false;
9577 assert!(!picker.selected_has_key());
9578 picker.handle_key(key(KeyCode::Char('e')));
9579 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9580 let rendered = render_text(&picker, 100, 24);
9581 assert_eq!(picker.choice_row_hitboxes.borrow().len(), 1);
9582 assert!(!rendered.contains("Import Codex"));
9583 assert!(!rendered.contains("external Codex reuse"));
9584 picker.handle_key(key(KeyCode::Char('2')));
9585 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9586 assert!(matches!(
9587 picker.handle_key(key(KeyCode::Enter)),
9588 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
9589 ));
9590 picker.handle_key(key(KeyCode::Esc));
9591 picker.handle_key(key(KeyCode::Enter));
9592 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9593 picker.enter_key_entry();
9594 let rendered = render_text(&picker, 100, 24);
9595 assert!(rendered.contains("Sign in with ChatGPT"));
9596 assert!(!rendered.contains("CODEX_ACCESS_TOKEN"));
9597 assert!(!rendered.contains("codex login"));
9598 assert!(!rendered.contains("external-consent"));
9599 assert!(matches!(
9600 picker.handle_key(key(KeyCode::Enter)),
9601 ViewAction::EmitAndClose(ViewEvent::ProviderPickerChatgptOAuthRequested)
9602 ));
9603 }
9604
9605 #[test]
9606 fn chatgpt_provider_models_require_own_roster_and_keep_provider_order() {
9607 let _env = crate::test_support::lock_test_env();
9608 let home = tempfile::tempdir().unwrap();
9609 let home_path = home.path().canonicalize().unwrap();
9610 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home_path);
9611 let mut config = Config::default();
9612 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9613 let row = picker
9614 .rows
9615 .iter()
9616 .find(|row| row.provider == ProviderKind::OpenaiCodex)
9617 .unwrap();
9618 assert!(provider_pane_models(&config, row, 8).is_empty());
9619 crate::oauth::install_test_chatgpt_registration(&mut config).unwrap();
9620 crate::codex_model_cache::install_test_chatgpt_roster(&config, &["z-first", "a-second"])
9621 .unwrap();
9622 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9623 let row = picker
9624 .rows
9625 .iter()
9626 .find(|row| row.provider == ProviderKind::OpenaiCodex)
9627 .unwrap();
9628 assert_eq!(
9629 provider_pane_models(&config, row, 8)
9630 .into_iter()
9631 .map(|(id, _, _)| id)
9632 .collect::<Vec<_>>(),
9633 ["z-first", "a-second"]
9634 );
9635 let picker = ProviderPickerView::new_for_model_pick_after_validation(
9636 ProviderKind::Deepseek,
9637 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
9638 &config,
9639 None,
9640 String::new(),
9641 None,
9642 )
9643 .unwrap();
9644 assert_eq!(picker.model_options, ["z-first", "a-second"]);
9645 }
9646
9647 #[test]
9648 fn external_consent_surface_uses_the_selected_locale() {
9649 let config = Config::default();
9650 let mut picker = ProviderPickerView::new_for_missing_auth(
9651 ProviderKind::Deepseek,
9652 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
9653 &config,
9654 None,
9655 )
9656 .expect("OpenAI Codex has a picker row")
9657 .with_locale(codewhale_localization::Locale::ZhHans);
9658
9659 picker.enter_external_consent_choice();
9660 let choices = render_text(&picker, 100, 20);
9661 let compact = choices
9662 .chars()
9663 .filter(|ch| !ch.is_whitespace())
9664 .collect::<String>();
9665 assert!(compact.contains("外部凭据访问"), "{choices}");
9666 assert!(compact.contains("禁用(默认)"), "{choices}");
9667 assert!(compact.contains("托管(不可用)"), "{choices}");
9668 }
9669
9670 #[test]
9671 fn xai_auth_choice_keeps_api_key_device_oauth_and_external_reuse_distinct() {
9672 let config = Config::default();
9673 let mut picker = ProviderPickerView::new_for_missing_auth(
9674 ProviderKind::Deepseek,
9675 &(config).test_identity_for_kind(ProviderKind::Xai),
9676 &config,
9677 None,
9678 )
9679 .expect("xAI has a picker row");
9680 assert_eq!(picker.stage, Stage::XaiAuthChoice);
9681
9682 let rendered = render_text(&picker, 96, 20);
9683 assert!(rendered.contains("xAI API key"));
9684 assert!(rendered.contains("Native device OAuth"));
9685 assert!(rendered.contains("Codewhale-owned storage"));
9686 picker.handle_key(key(KeyCode::Char('2')));
9687 assert!(matches!(
9688 picker.handle_key(key(KeyCode::Enter)),
9689 ViewAction::EmitAndClose(ViewEvent::ProviderPickerXaiOAuthRequested)
9690 ));
9691
9692 let mut external = ProviderPickerView::new_for_missing_auth(
9693 ProviderKind::Deepseek,
9694 &(config).test_identity_for_kind(ProviderKind::Xai),
9695 &config,
9696 None,
9697 )
9698 .expect("xAI has a picker row");
9699 assert!(matches!(
9700 external.handle_key(key(KeyCode::Char('e'))),
9701 ViewAction::None
9702 ));
9703 assert_eq!(external.stage, Stage::ExternalConsentChoice);
9704 let rendered = render_text(&external, 100, 20);
9705 assert!(rendered.contains("Managed (unavailable)"), "{rendered}");
9706 }
9707
9708 #[test]
9709 fn xai_auth_choice_uses_the_selected_locale() {
9710 let config = Config::default();
9711 let picker = ProviderPickerView::new_for_missing_auth(
9712 ProviderKind::Deepseek,
9713 &(config).test_identity_for_kind(ProviderKind::Xai),
9714 &config,
9715 None,
9716 )
9717 .expect("xAI has a picker row")
9718 .with_locale(codewhale_localization::Locale::ZhHans);
9719
9720 let rendered = render_text(&picker, 100, 24);
9721 let compact = rendered
9722 .chars()
9723 .filter(|ch| !ch.is_whitespace())
9724 .collect::<String>();
9725 for translated in [
9726 "xAI身份验证",
9727 "请选择一个明确的凭据来源",
9728 "xAIAPI密钥",
9729 "原生设备OAuth",
9730 ] {
9731 assert!(compact.contains(translated), "{translated}: {rendered}");
9732 }
9733 assert!(!rendered.contains("Choose one explicit credential source"));
9734 assert!(!rendered.contains("Native device OAuth"));
9735 }
9736
9737 #[test]
9738 fn xai_auth_status_distinguishes_oauth_from_api_key_auth() {
9739 let oauth_config = crate::config::ProviderConfig {
9740 auth_mode: Some("oauth".to_string()),
9741 ..Default::default()
9742 };
9743 assert_eq!(
9744 xai_oauth_status(Some(&oauth_config), false),
9745 Some(ProviderAuthStatus::OAuthMissing)
9746 );
9747 assert_eq!(
9748 xai_oauth_status(Some(&oauth_config), true),
9749 Some(ProviderAuthStatus::OAuthReady)
9750 );
9751 assert_eq!(xai_oauth_status(None, true), None);
9752 assert_eq!(xai_oauth_status(None, false), None);
9753
9754 let fallback_key = crate::config::ProviderConfig {
9755 auth_mode: Some("oauth".to_string()),
9756 api_key: Some("xai-api-key".to_string()),
9757 ..Default::default()
9758 };
9759 assert_eq!(
9760 xai_oauth_status(Some(&fallback_key), false),
9761 Some(ProviderAuthStatus::Configured)
9762 );
9763 for sentinel in [crate::config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
9764 let placeholder = crate::config::ProviderConfig {
9765 auth_mode: Some("oauth".to_string()),
9766 api_key: Some(sentinel.to_string()),
9767 ..Default::default()
9768 };
9769 assert_eq!(
9770 xai_oauth_status(Some(&placeholder), false),
9771 Some(ProviderAuthStatus::OAuthMissing)
9772 );
9773 }
9774 }
9775
9776 #[test]
9777 fn inactive_external_consents_are_visible_without_io_and_never_enter_routing_inventory() {
9778 let _env = crate::test_support::lock_test_env();
9779 let temp = tempfile::tempdir().expect("external consent fixtures");
9780 let codex_path = temp.path().join("codex-auth.json");
9781 let grok_path = temp.path().join("grok-auth.json");
9782 let codex_raw = "codex-external-file-must-not-be-read";
9783 let grok_raw = "grok-external-file-must-not-be-read";
9784 std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
9785 std::fs::write(&grok_path, grok_raw).expect("write Grok trap");
9786 let owned_home = temp.path().join("codewhale-owned");
9787
9788 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
9789 let _codex_path =
9790 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
9791 let _grok_path = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
9792 let _codex_access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
9793 let _legacy_codex_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
9794 let _xai_key = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
9795 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
9796 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
9797
9798 let config = Config {
9799 provider: Some(ProviderKind::Deepseek.as_str().to_string()),
9800 providers: Some(crate::config::ProvidersConfig {
9801 openai_codex: crate::config::ProviderConfig {
9802 auth_mode: Some("oauth".to_string()),
9803 external_credentials: Some(
9804 codewhale_config::ExternalCredentialConsentToml::read_only(
9805 codewhale_config::ProviderKind::OpenaiCodex,
9806 codewhale_config::ExternalCredentialSource::CodexCli,
9807 codex_path.clone(),
9808 ),
9809 ),
9810 ..Default::default()
9811 },
9812 xai: crate::config::ProviderConfig {
9813 auth_mode: Some("oauth".to_string()),
9814 external_credentials: Some(
9815 codewhale_config::ExternalCredentialConsentToml::read_only(
9816 codewhale_config::ProviderKind::Xai,
9817 codewhale_config::ExternalCredentialSource::GrokCli,
9818 grok_path.clone(),
9819 ),
9820 ),
9821 ..Default::default()
9822 },
9823 ..Default::default()
9824 }),
9825 ..Default::default()
9826 };
9827
9828 crate::external_credentials::reset_side_effect_trap();
9829 assert!(!has_api_key_for(
9830 &config,
9831 &(config).test_identity_for_kind(ProviderKind::OpenaiCodex)
9832 ));
9833 assert!(!has_api_key_for(
9834 &config,
9835 &(config).test_identity_for_kind(ProviderKind::Xai)
9836 ));
9837
9838 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
9839 for provider in [ProviderKind::OpenaiCodex, ProviderKind::Xai] {
9840 let index = picker
9841 .rows
9842 .iter()
9843 .position(|row| row.provider == provider)
9844 .expect("consented provider row");
9845 let row = &picker.rows[index];
9846 assert_eq!(row.credential_state, CredentialState::ExternalConsent);
9847 assert_eq!(row.auth_status, ProviderAuthStatus::OAuthConsented);
9848 let structural = row
9849 .external_credential_status
9850 .as_ref()
9851 .expect("external status");
9852 assert_eq!(structural.access.as_str(), "read_only");
9853 assert_eq!(structural.route_state, "dormant");
9854 assert!(structural.revoke_command.contains(provider.as_str()));
9855 assert_eq!(
9856 row.readiness,
9857 ResolvedProviderReadiness::ExternalConsentPendingSelection
9858 );
9859 assert!(!row.readiness.can_attempt());
9860 picker.selected_idx = index;
9861 let visible = render_text(&picker, 140, 32);
9862 assert!(visible.contains("External: access=read_only"), "{visible}");
9863 // #5772: the row names the owning CLI but never the file path —
9864 // the exact path is disclosed only inside the explicit reuse
9865 // confirmation.
9866 assert!(visible.contains("Owner:"), "{visible}");
9867 assert!(!visible.contains("Owner/path:"), "{visible}");
9868 let pinned = codewhale_config::quote_os_path(match provider {
9869 ProviderKind::OpenaiCodex => &codex_path,
9870 _ => &grok_path,
9871 });
9872 assert!(
9873 !visible.contains(&pinned),
9874 "ordinary browsing must not disclose {pinned}: {visible}"
9875 );
9876 assert!(
9877 visible.contains("revoke: codewhale auth external-revoke"),
9878 "{visible}"
9879 );
9880 if provider == ProviderKind::OpenaiCodex {
9881 assert!(!picker.selected_has_key());
9882 assert!(matches!(
9883 picker.handle_key(key(KeyCode::Enter)),
9884 ViewAction::None
9885 ));
9886 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
9887 picker.stage = Stage::List;
9888 } else {
9889 assert!(
9890 picker.selected_has_key(),
9891 "selecting {provider:?} should activate the consented route before checking it"
9892 );
9893 assert!(
9894 matches!(picker.handle_key(key(KeyCode::Enter)), ViewAction::EmitAndClose(ViewEvent::ProviderPickerApplied { identity: selected, .. }) if selected.provider == provider)
9895 );
9896 }
9897 }
9898 // #5772: revocation requires its own confirmation and clears only
9899 // Codewhale-owned consent state.
9900 assert!(matches!(
9901 picker.handle_key(key(KeyCode::Char('x'))),
9902 ViewAction::None
9903 ));
9904 assert_eq!(picker.stage, Stage::ExternalConsentRevokeConfirm);
9905 let revoke_render = render_text(&picker, 120, 16);
9906 assert!(
9907 revoke_render.contains("clears only Codewhale's consent record"),
9908 "{revoke_render}"
9909 );
9910 assert!(
9911 !revoke_render.contains(&codewhale_config::quote_os_path(&grok_path)),
9912 "revocation never inspects or names the external file: {revoke_render}"
9913 );
9914 assert!(matches!(
9915 picker.handle_key(key(KeyCode::Esc)),
9916 ViewAction::None
9917 ));
9918 // `x` was pressed from the list, so Esc returns to the list — "back"
9919 // means the step the user actually came from (#5772).
9920 assert_eq!(picker.stage, Stage::List);
9921 assert!(matches!(
9922 picker.handle_key(key(KeyCode::Char('x'))),
9923 ViewAction::None
9924 ));
9925 assert!(matches!(
9926 picker.handle_key(key(KeyCode::Enter)),
9927 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
9928 provider: ProviderKind::Xai
9929 })
9930 ));
9931
9932 let inventory = crate::model_inventory::ModelInventory::from_config(&config).unwrap();
9933 assert!(
9934 inventory.candidates.iter().all(|candidate| !matches!(
9935 candidate.provider,
9936 ProviderKind::OpenaiCodex | ProviderKind::Xai
9937 )),
9938 "dormant external-only routes must not reach auto-routing inventory"
9939 );
9940 assert_eq!(
9941 crate::route_billing::for_route(
9942 &config,
9943 &(config).test_identity_for_kind(ProviderKind::Xai)
9944 ),
9945 crate::route_billing::BillingPresentation::Metered
9946 );
9947 assert_eq!(
9948 crate::external_credentials::side_effect_trap_counts(),
9949 (0, 0),
9950 "picker, readiness, billing, and model inventory must not inspect inactive external files"
9951 );
9952 assert_eq!(
9953 std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
9954 codex_raw
9955 );
9956 assert_eq!(
9957 std::fs::read_to_string(&grok_path).expect("Grok trap unchanged"),
9958 grok_raw
9959 );
9960 assert!(!owned_home.join("credentials/xai-auth.json").exists());
9961 }
9962
9963 /// Modal body text with layout removed: ratatui word-wraps a long path
9964 /// across rows and pads each row to the modal edge, so a `contains` check
9965 /// against a full path is a check on the terminal width, not on what was
9966 /// disclosed. Strip whitespace and box-drawing glyphs so the assertion is
9967 /// about the content.
9968 fn unwrapped_modal_text(rendered: &str) -> String {
9969 rendered
9970 .chars()
9971 .filter(|ch| !ch.is_whitespace() && !('\u{2500}'..='\u{257f}').contains(ch))
9972 .collect()
9973 }
9974
9975 /// #5772: with reuse off, ordinary browsing and plain Enter perform zero
9976 /// external I/O and never mint, persist, or reveal an external credential
9977 /// grant. The legacy adapter is exercised directly for diagnostics;
9978 /// ordinary ChatGPT setup offers the official browser sign-in only.
9979 #[test]
9980 fn unconsented_external_row_performs_no_io_and_grants_only_after_confirmation() {
9981 // Constructing and rendering the full provider picker is intentionally
9982 // broad: the regression has to prove that ordinary catalog browsing,
9983 // key entry, and both consent modals all preserve the same no-I/O
9984 // boundary. That state is larger than libtest's default thread stack,
9985 // while the product TUI runs on a deliberately larger stack. Mirror
9986 // the product/test precedent instead of requiring RUST_MIN_STACK in CI.
9987 std::thread::Builder::new()
9988 .name("provider-consent-no-io".to_string())
9989 .stack_size(16 * 1024 * 1024)
9990 .spawn(unconsented_external_row_performs_no_io_on_sized_stack)
9991 .expect("spawn provider-consent regression thread")
9992 .join()
9993 .expect("provider-consent regression thread");
9994 }
9995
9996 fn unconsented_external_row_performs_no_io_on_sized_stack() {
9997 let _env = crate::test_support::lock_test_env();
9998 let temp = tempfile::tempdir().expect("external reuse fixtures");
9999 let codex_path = temp
10000 .path()
10001 .canonicalize()
10002 .expect("canonical temp root")
10003 .join("auth.json");
10004 // A fresh-looking token file exists on disk; nothing may look at it.
10005 let codex_raw = "{\"tokens\":{\"access_token\":\"header.eyJleHAiOjk5OTk5OTk5OTl9.sig\"}}";
10006 std::fs::write(&codex_path, codex_raw).expect("write Codex trap");
10007
10008 let _codex_home =
10009 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
10010 let _codex_access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
10011 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
10012 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
10013
10014 let config = Config {
10015 provider: Some(ProviderKind::Deepseek.as_str().to_string()),
10016 ..Default::default()
10017 };
10018
10019 crate::external_credentials::reset_side_effect_trap();
10020 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10021 move_to_provider(&mut picker, ProviderKind::OpenaiCodex);
10022 let quoted = codewhale_config::quote_os_path(&codex_path);
10023 let quoted_unwrapped = unwrapped_modal_text(&quoted);
10024
10025 // Ordinary browsing reveals neither the path nor the file's existence.
10026 let visible = render_text(&picker, 140, 32);
10027 assert!(
10028 !unwrapped_modal_text(&visible).contains(&quoted_unwrapped),
10029 "ordinary browsing must not disclose {quoted}: {visible}"
10030 );
10031
10032 // Plain Enter begins ordinary setup; it must not read the external
10033 // file, mint a grant, or emit the consent-confirmed event. #5778 puts
10034 // openai-codex behind the ChatGPT auth choice first; entering that
10035 // stage stays inside the no-I/O boundary.
10036 assert!(matches!(
10037 picker.handle_key(key(KeyCode::Enter)),
10038 ViewAction::None
10039 ));
10040 assert_eq!(picker.stage, Stage::ChatgptAuthChoice);
10041 assert_eq!(
10042 crate::external_credentials::complete_side_effect_trap_counts(),
10043 (0, 0, 0, 0, 0),
10044 "ordinary selection must not touch external credential state"
10045 );
10046
10047 // Enter the legacy adapter directly; its choice stage still hides
10048 // the path and performs no I/O.
10049 picker.enter_external_consent_choice();
10050 assert_eq!(picker.stage, Stage::ExternalConsentChoice);
10051 let choices = render_text(&picker, 100, 20);
10052 assert!(
10053 choices.contains("Use external CLI credentials (read-only)"),
10054 "{choices}"
10055 );
10056 assert!(
10057 !unwrapped_modal_text(&choices).contains(&quoted_unwrapped),
10058 "the choice stage must not disclose {quoted}: {choices}"
10059 );
10060
10061 // …the confirmation stage discloses the exact candidate path…
10062 picker.handle_key(key(KeyCode::Char('2')));
10063 picker.handle_key(key(KeyCode::Enter));
10064 assert_eq!(picker.stage, Stage::ExternalConsentConfirm);
10065 let confirm = render_text(&picker, 120, 30);
10066 assert!(
10067 unwrapped_modal_text(&confirm).contains(&quoted_unwrapped),
10068 "confirmation must name the exact path: {confirm}"
10069 );
10070 // …and even rendering the confirmation performed no external I/O.
10071 assert_eq!(
10072 crate::external_credentials::complete_side_effect_trap_counts(),
10073 (0, 0, 0, 0, 0),
10074 "disclosure must not validate or read before affirmative confirmation"
10075 );
10076
10077 // Only the affirmative Enter on the confirmation emits the grant event.
10078 assert!(matches!(
10079 picker.handle_key(key(KeyCode::Enter)),
10080 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentConfirmed {
10081 provider: ProviderKind::OpenaiCodex,
10082 ..
10083 })
10084 ));
10085 assert_eq!(
10086 std::fs::read_to_string(&codex_path).expect("Codex trap unchanged"),
10087 codex_raw
10088 );
10089 }
10090
10091 /// #5772: revoking external access requires its own confirmation; a bare
10092 /// `x` chord only opens the disclosure.
10093 #[test]
10094 fn revoke_requires_confirmation() {
10095 let _env = crate::test_support::lock_test_env();
10096 let temp = tempfile::tempdir().expect("revoke fixtures");
10097 let codex_path = temp
10098 .path()
10099 .canonicalize()
10100 .expect("canonical temp root")
10101 .join("auth.json");
10102 std::fs::write(&codex_path, "codex-external-file-must-not-be-read").expect("trap");
10103 let _codex_home =
10104 crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
10105 let _codex_access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
10106 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
10107 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
10108
10109 let config = Config {
10110 provider: Some(ProviderKind::Deepseek.as_str().to_string()),
10111 providers: Some(crate::config::ProvidersConfig {
10112 openai_codex: crate::config::ProviderConfig {
10113 auth_mode: Some("oauth".to_string()),
10114 external_credentials: Some(
10115 codewhale_config::ExternalCredentialConsentToml::read_only(
10116 codewhale_config::ProviderKind::OpenaiCodex,
10117 codewhale_config::ExternalCredentialSource::CodexCli,
10118 codex_path.clone(),
10119 ),
10120 ),
10121 ..Default::default()
10122 },
10123 ..Default::default()
10124 }),
10125 ..Default::default()
10126 };
10127
10128 crate::external_credentials::reset_side_effect_trap();
10129 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10130 move_to_provider(&mut picker, ProviderKind::OpenaiCodex);
10131
10132 assert!(matches!(
10133 picker.handle_key(key(KeyCode::Char('x'))),
10134 ViewAction::None
10135 ));
10136 assert_eq!(picker.stage, Stage::ExternalConsentRevokeConfirm);
10137 assert_eq!(
10138 crate::external_credentials::complete_side_effect_trap_counts(),
10139 (0, 0, 0, 0, 0),
10140 "revocation disclosure must not inspect the external file"
10141 );
10142 assert!(matches!(
10143 picker.handle_key(key(KeyCode::Enter)),
10144 ViewAction::EmitAndClose(ViewEvent::ProviderPickerExternalConsentRevoked {
10145 provider: ProviderKind::OpenaiCodex
10146 })
10147 ));
10148 }
10149
10150 #[test]
10151 fn kimi_cli_token_is_never_auto_enabled_without_explicit_legacy_auth_mode() {
10152 let _env = crate::test_support::lock_test_env();
10153 let temp = tempfile::tempdir().expect("Kimi import fixture root");
10154 let kimi_home = temp.path().join("kimi-code");
10155 std::fs::create_dir_all(kimi_home.join("credentials"))
10156 .expect("Kimi import credential directory");
10157 let expires_at = std::time::SystemTime::now()
10158 .duration_since(std::time::UNIX_EPOCH)
10159 .expect("clock after epoch")
10160 .as_secs_f64()
10161 + 3600.0;
10162 std::fs::write(
10163 kimi_home.join("credentials/kimi-code.json"),
10164 serde_json::json!({
10165 "access_token": "unexpired-user-owned-token",
10166 "refresh_token": "must-not-be-used",
10167 "expires_at": expires_at,
10168 })
10169 .to_string(),
10170 )
10171 .expect("write Kimi import fixture");
10172 let _kimi_home = crate::test_support::EnvVarGuard::set(
10173 "KIMI_CODE_HOME",
10174 kimi_home.to_str().expect("utf8 path"),
10175 );
10176 let _moonshot_key = crate::test_support::EnvVarGuard::remove("MOONSHOT_API_KEY");
10177 let _kimi_key = crate::test_support::EnvVarGuard::remove("KIMI_API_KEY");
10178
10179 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &Config::default());
10180 move_to_provider(&mut picker, ProviderKind::Moonshot);
10181 let row = &picker.rows[picker.selected_idx];
10182 assert_eq!(row.auth_status, ProviderAuthStatus::Missing);
10183 assert_eq!(row.credential_state, CredentialState::MissingKey);
10184
10185 assert!(matches!(
10186 picker.handle_key(key(KeyCode::Enter)),
10187 ViewAction::None
10188 ));
10189 assert_eq!(
10190 picker.stage,
10191 Stage::KeyEntry,
10192 "a stray Kimi CLI credential must lead to API-key setup, not import activation"
10193 );
10194 }
10195
10196 #[test]
10197 fn explicit_legacy_kimi_import_is_unavailable_and_routes_to_api_key_setup() {
10198 let _env = crate::test_support::lock_test_env();
10199 let temp = tempfile::tempdir().expect("Kimi import fixture root");
10200 let kimi_home = temp.path().join("kimi-code");
10201 std::fs::create_dir_all(kimi_home.join("credentials"))
10202 .expect("Kimi import credential directory");
10203 let expires_at = std::time::SystemTime::now()
10204 .duration_since(std::time::UNIX_EPOCH)
10205 .expect("clock after epoch")
10206 .as_secs_f64()
10207 + 3600.0;
10208 std::fs::write(
10209 kimi_home.join("credentials/kimi-code.json"),
10210 serde_json::json!({
10211 "access_token": "unexpired-user-owned-token",
10212 "refresh_token": "must-not-be-used",
10213 "expires_at": expires_at,
10214 })
10215 .to_string(),
10216 )
10217 .expect("write Kimi import fixture");
10218 let _kimi_home = crate::test_support::EnvVarGuard::set(
10219 "KIMI_CODE_HOME",
10220 kimi_home.to_str().expect("utf8 path"),
10221 );
10222 let config = Config {
10223 providers: Some(crate::config::ProvidersConfig {
10224 moonshot: crate::config::ProviderConfig {
10225 auth_mode: Some("kimi_oauth".to_string()),
10226 ..Default::default()
10227 },
10228 ..Default::default()
10229 }),
10230 ..Default::default()
10231 };
10232
10233 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10234 move_to_provider(&mut picker, ProviderKind::Moonshot);
10235 let row = &picker.rows[picker.selected_idx];
10236 assert_eq!(
10237 row.auth_status,
10238 ProviderAuthStatus::ImportedTokenUnavailable
10239 );
10240 assert_eq!(row.credential_state, CredentialState::MissingKey);
10241 assert_eq!(row.base_url, crate::config::DEFAULT_KIMI_CODE_BASE_URL);
10242 assert_eq!(
10243 row.default_route.logical_model,
10244 crate::config::DEFAULT_KIMI_CODE_MODEL
10245 );
10246 // Slice D: the Kimi key guidance travels on messages, not on a
10247 // provider-level meter.
10248 assert!(
10249 row.messages
10250 .iter()
10251 .any(|message| message.contains("Kimi API key")),
10252 "missing Kimi key guidance: {:?}",
10253 row.messages
10254 );
10255 assert_eq!(row.readiness, ResolvedProviderReadiness::MissingKey);
10256 assert!(matches!(
10257 picker.handle_key(key(KeyCode::Enter)),
10258 ViewAction::None
10259 ));
10260 assert_eq!(picker.stage, Stage::KeyEntry);
10261 }
10262
10263 #[test]
10264 fn key_entry_esc_returns_to_list_without_emitting() {
10265 let config = Config::default();
10266 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10267 move_to_provider(&mut picker, ProviderKind::Openrouter);
10268 picker.handle_key(key(KeyCode::Enter));
10269 assert_eq!(picker.stage, Stage::KeyEntry);
10270 picker.handle_key(key(KeyCode::Char('a')));
10271 let action = picker.handle_key(key(KeyCode::Esc));
10272 assert!(matches!(action, ViewAction::None));
10273 assert_eq!(picker.stage, Stage::List);
10274 assert!(picker.api_key_input.is_empty());
10275 }
10276
10277 #[test]
10278 fn list_esc_emits_dismiss_memory() {
10279 let config = Config::default();
10280 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10281 let action = picker.handle_key(key(KeyCode::Esc));
10282 assert!(matches!(
10283 action,
10284 ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed { .. })
10285 ));
10286 }
10287
10288 #[test]
10289 fn key_entry_strips_whitespace_chars() {
10290 let config = Config::default();
10291 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10292 move_to_provider(&mut picker, ProviderKind::Openrouter);
10293 picker.handle_key(key(KeyCode::Enter));
10294 assert_eq!(picker.stage, Stage::KeyEntry);
10295 for c in "abc def".chars() {
10296 picker.handle_key(key(KeyCode::Char(c)));
10297 }
10298 assert_eq!(picker.api_key_input, "abcdef");
10299 }
10300
10301 #[test]
10302 fn small_list_render_keeps_selected_provider_visible_after_down_navigation() {
10303 let config = Config::default();
10304 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10305 move_to_provider(&mut picker, ProviderKind::Ollama);
10306
10307 let rendered = render_text(&picker, 80, 12);
10308
10309 assert!(rendered.contains("Ollama"));
10310 assert!(!rendered.contains("DeepSeek *"));
10311 }
10312
10313 #[test]
10314 fn small_list_render_keeps_initial_active_provider_visible() {
10315 let config = Config {
10316 provider: Some("ollama".into()),
10317 ..Config::default()
10318 };
10319 let picker = ProviderPickerView::new(ProviderKind::Ollama, &config);
10320
10321 let rendered = render_text(&picker, 80, 12);
10322
10323 assert!(rendered.contains("Ollama *"));
10324 }
10325
10326 #[test]
10327 fn tall_catalog_render_shows_selected_provider_details() {
10328 let config = Config::default();
10329 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10330 // "All providers" means the full catalog (#3830), not just configured.
10331 picker.toggle_view();
10332
10333 let rendered = render_text(&picker, 80, 23);
10334
10335 assert!(rendered.contains("DeepSeek *"));
10336 assert!(rendered.contains(picker.tr(MessageId::CtxMenuOpenDetails).as_ref()));
10337 assert!(rendered.contains("Model:"));
10338 }
10339
10340 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires every
10341 /// overlay to remain readable and fully operable at.
10342 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
10343
10344 #[test]
10345 fn provider_picker_is_usable_and_opaque_at_blocker_sizes() {
10346 use crate::tui::views::ViewStack;
10347 // Provider display names contain capital X/Q (Xiaomi MiMo, Qianfan), so
10348 // use a glyph that can never appear in the modal content as the
10349 // bleed-through sentinel.
10350 const SENTINEL: &str = "\u{2592}"; // ▒
10351 let config = Config::default();
10352 // Make the first provider in the sorted list active so its highlighted
10353 // row sits at the top of the list, never on the vertical center cell
10354 // that must read as the opaque modal ink.
10355 let active = ProviderPickerView::new(ProviderKind::Deepseek, &config).rows[0].provider;
10356
10357 for (w, h) in BLOCKER_SIZES {
10358 let area = Rect::new(0, 0, w, h);
10359 let mut buf = Buffer::empty(area);
10360 for y in 0..h {
10361 for x in 0..w {
10362 buf[(x, y)].set_symbol(SENTINEL);
10363 }
10364 }
10365 // Render through the ViewStack so the shared opaque backdrop is
10366 // painted exactly as it is in production.
10367 let mut stack = ViewStack::new();
10368 stack.push(ProviderPickerView::new(active, &config));
10369 stack.render(area, &mut buf);
10370
10371 let rows: Vec<String> = (0..h)
10372 .map(|y| {
10373 (0..w)
10374 .map(|x| buf[(x, y)].symbol().to_string())
10375 .collect::<String>()
10376 })
10377 .collect();
10378 let text = rows.join("\n");
10379
10380 // Footer keeps every action (it wraps instead of clipping).
10381 for label in ["move", "search", "edit key", "models", "cancel"] {
10382 assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
10383 }
10384 // The Enter action label is dynamic (apply vs set key); one shows.
10385 assert!(
10386 text.contains("apply") || text.contains("set key"),
10387 "{w}x{h}: missing Enter action label"
10388 );
10389 // Composited frame is fully opaque: no sentinel survives and the
10390 // center cell carries the modal ink background.
10391 assert!(
10392 !text.contains(SENTINEL),
10393 "{w}x{h}: background bleed-through into modal surface"
10394 );
10395 assert_eq!(
10396 buf[(w / 2, h / 2)].bg,
10397 palette::WHALE_BG,
10398 "{w}x{h}: modal interior must be opaque"
10399 );
10400 // No row exceeds the frame width (no horizontal overflow).
10401 for (y, row) in rows.iter().enumerate() {
10402 assert!(
10403 unicode_width::UnicodeWidthStr::width(row.trim_end()) <= w as usize,
10404 "{w}x{h}: row {y} overflows width: {row:?}"
10405 );
10406 }
10407 }
10408 }
10409
10410 #[test]
10411 fn selected_provider_row_uses_strong_highlight() {
10412 let config = Config::default();
10413 let picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10414 let area = Rect::new(0, 0, 80, 20);
10415 let mut buf = Buffer::empty(area);
10416
10417 picker.render(area, &mut buf);
10418
10419 let highlighted_cells = area
10420 .positions()
10421 .filter(|position| {
10422 let cell = &buf[*position];
10423 cell.bg == palette::SELECTION_BG
10424 })
10425 .count();
10426 assert!(
10427 highlighted_cells >= 32,
10428 "selected provider row should use a visible continuous highlight"
10429 );
10430 }
10431
10432 #[test]
10433 fn search_footer_shows_two_stage_esc_as_a_single_hint() {
10434 let config = Config::default();
10435 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10436 picker.query = "deep".to_string();
10437 let area = Rect::new(0, 0, 100, 24);
10438 let mut buf = Buffer::empty(area);
10439
10440 picker.render(area, &mut buf);
10441
10442 let text = area
10443 .positions()
10444 .map(|position| buf[position].symbol())
10445 .collect::<String>();
10446 // The key appears once, with both stages spelled out in its label.
10447 assert_eq!(
10448 text.matches(" Esc ").count(),
10449 1,
10450 "search footer must not duplicate the Esc key: {text}"
10451 );
10452 assert!(text.contains("clear / cancel"), "{text}");
10453 }
10454
10455 #[test]
10456 fn esc_reports_browsing_context_and_reopen_restores_it() {
10457 let config = Config::default();
10458 let mut picker = ProviderPickerView::new(ProviderKind::Deepseek, &config);
10459 // Browse full catalog and move highlight.
10460 picker.handle_key(key(KeyCode::Char('a')));
10461 picker.handle_key(key(KeyCode::Down));
10462 let remembered_id = picker.rows[picker.selected_idx].provider_id.clone();
10463 let action = picker.handle_key(key(KeyCode::Esc));
10464 let ViewAction::EmitAndClose(ViewEvent::ProviderPickerDismissed {
10465 catalog_view,
10466 selected_provider_id,
10467 }) = action
10468 else {
10469 panic!("expected ProviderPickerDismissed");
10470 };
10471 assert!(catalog_view);
10472 assert_eq!(
10473 selected_provider_id.as_deref(),
10474 Some(remembered_id.as_str())
10475 );
10476
10477 let memory = crate::tui::app::ProviderPickerMemory {
10478 catalog_view,
10479 selected_provider_id,
10480 };
10481 let reopened = ProviderPickerView::new_with_runtime_status_and_memory(
10482 ProviderKind::Deepseek,
10483 &config,
10484 None,
10485 Some(&memory),
10486 );
10487 assert_eq!(reopened.view, ProviderListView::Catalog);
10488 assert_eq!(
10489 reopened.rows[reopened.selected_idx].provider_id,
10490 remembered_id
10491 );
10492 }
10493 }
10494
10494 lines RUST