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