返回 CodeWhale
model_picker.rs
根目录 / crates / tui / src / tui / model_picker.rs
1 //! `/model` picker modal: pick a model and thinking-effort tier (#39, #2026).
2 //!
3 //! The picker intentionally presents model and thinking as independent choices
4 //! instead of collapsing them into preset route names. The "auto" option is
5 //! always available; custom (unrecognized) model ids appear as a separate row.
6 //! Pass-through providers fall back to only "auto" plus the current custom row.
7 //!
8 //! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
9 //! model id and effort tier.
10
11 use std::cell::RefCell;
12 use std::collections::BTreeMap;
13
14 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
15 use ratatui::{
16 buffer::Buffer,
17 layout::Rect,
18 style::{Modifier, Style},
19 text::{Line, Span},
20 widgets::{Block, Paragraph, Widget},
21 };
22
23 use codewhale_config::catalog::CatalogSource;
24 use codewhale_config::model_reference::ModelReferenceCard;
25 use codewhale_config::pricing::OfferingPricing;
26
27 use crate::codex_model_cache::{
28 self, CodexModelCacheFreshness, CodexModelMetadata, CodexModelRoster,
29 };
30 use crate::config::{ApiProvider, Config, DEEPSEEK_ALIAS_REPLACEMENT};
31 use crate::localization::{Locale, MessageId, tr};
32 use crate::model_profile::{
33 CapabilityOverride, SupportState, resolved_capability_profile_for_route_with_overrides,
34 resolved_capability_profile_with_overrides,
35 };
36 use crate::model_registry;
37 use crate::models_dev_live::{self, ModelsDevFreshness};
38 use crate::palette;
39 use crate::provider_lake::{
40 all_catalog_models_for_provider, catalog_offering_for_model, configured_providers,
41 };
42 use crate::settings::PinnedModel;
43 use crate::tui::app::{App, ReasoningEffort};
44 use crate::tui::menu_style;
45 use crate::tui::views::{
46 ActionHint, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
47 render_underwater_surface,
48 };
49
50 /// Thinking-effort rows shown for DeepSeek-style providers, in the order
51 /// DeepSeek behaviorally distinguishes them.
52 const DEFAULT_PICKER_EFFORTS: &[ReasoningEffort] = &[
53 ReasoningEffort::Auto,
54 ReasoningEffort::Off,
55 ReasoningEffort::High,
56 ReasoningEffort::Max,
57 ];
58 /// First-party DeepSeek routes document a real `low` wire tier alongside
59 /// `high`/`max` (#52), so their picker exposes the cheaper tier the generic
60 /// default list cannot claim for routes where low collapses onto high.
61 const DEEPSEEK_PICKER_EFFORTS: &[ReasoningEffort] = &[
62 ReasoningEffort::Auto,
63 ReasoningEffort::Off,
64 ReasoningEffort::Low,
65 ReasoningEffort::High,
66 ReasoningEffort::Max,
67 ];
68 /// Kimi Code K3 accepts route-specific low and medium controls at the
69 /// official membership endpoint. Medium becomes K3's nested high wire effort,
70 /// but keeping the selected intent visible is important for recovery and
71 /// route receipts.
72 const KIMI_CODE_K3_PICKER_EFFORTS: &[ReasoningEffort] = &[
73 ReasoningEffort::Auto,
74 ReasoningEffort::Off,
75 ReasoningEffort::Low,
76 ReasoningEffort::Medium,
77 ReasoningEffort::High,
78 ReasoningEffort::Max,
79 ];
80 const CODEX_PICKER_EFFORTS: &[ReasoningEffort] = &[
81 ReasoningEffort::Low,
82 ReasoningEffort::Medium,
83 ReasoningEffort::High,
84 ReasoningEffort::Max,
85 ];
86 /// Auto model routing has no concrete provider dialect yet, so retain the
87 /// complete preference vocabulary and defer normalization to dispatch.
88 const AUTO_MODEL_PICKER_EFFORTS: &[ReasoningEffort] = &[
89 ReasoningEffort::Auto,
90 ReasoningEffort::Off,
91 ReasoningEffort::Low,
92 ReasoningEffort::Medium,
93 ReasoningEffort::High,
94 ReasoningEffort::Max,
95 ];
96
97 /// `/model` catalog views (#4115).
98 ///
99 /// Configured stays the calm default. Typing searches every provider and a
100 /// cross-provider selection switches its route transactionally, so `/provider`
101 /// is never a prerequisite. Discoverability views (Recent / Coding / Cheap /
102 /// Long context) never auto-select a surprising route — the active model
103 /// remains the selection until the operator moves.
104 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
105 enum ModelListView {
106 Configured,
107 Catalog,
108 Recent,
109 Coding,
110 Cheap,
111 LongContext,
112 }
113
114 impl ModelListView {
115 const ALL: [Self; 6] = [
116 Self::Configured,
117 Self::Catalog,
118 Self::Recent,
119 Self::Coding,
120 Self::Cheap,
121 Self::LongContext,
122 ];
123
124 fn next(self) -> Self {
125 let idx = Self::ALL.iter().position(|view| *view == self).unwrap_or(0);
126 Self::ALL[(idx + 1) % Self::ALL.len()]
127 }
128
129 fn from_memory_name(name: &str) -> Option<Self> {
130 match name {
131 "configured" => Some(Self::Configured),
132 "catalog" => Some(Self::Catalog),
133 "recent" => Some(Self::Recent),
134 "coding" => Some(Self::Coding),
135 "cheap" => Some(Self::Cheap),
136 "long_context" => Some(Self::LongContext),
137 _ => None,
138 }
139 }
140
141 fn memory_name(self) -> &'static str {
142 match self {
143 Self::Configured => "configured",
144 Self::Catalog => "catalog",
145 Self::Recent => "recent",
146 Self::Coding => "coding",
147 Self::Cheap => "cheap",
148 Self::LongContext => "long_context",
149 }
150 }
151
152 /// Short chrome / action label for this view.
153 fn title_label(self) -> &'static str {
154 match self {
155 Self::Configured => "configured",
156 Self::Catalog => "catalog",
157 Self::Recent => "recent",
158 Self::Coding => "coding",
159 Self::Cheap => "cheap",
160 Self::LongContext => "long ctx",
161 }
162 }
163
164 /// Views that browse beyond the conservative configured-provider set.
165 fn is_discoverability(self) -> bool {
166 !matches!(self, Self::Configured)
167 }
168
169 fn browses_all_providers(self) -> bool {
170 self.is_discoverability()
171 }
172 }
173
174 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
175 enum Pane {
176 Model,
177 Effort,
178 }
179
180 #[derive(Debug, Clone, Copy)]
181 struct PaneRenderState {
182 pane: Pane,
183 selected: usize,
184 focused: bool,
185 }
186
187 pub struct ModelPickerView {
188 initial_model: String,
189 /// Exact runtime value before the picker opened. Keep this raw so choosing
190 /// the canonical replacement for a retired alias performs a real migration
191 /// instead of being misclassified as "unchanged".
192 previous_model: String,
193 initial_provider: ApiProvider,
194 /// Raw preference before the picker opened. An absent explicit preference
195 /// is represented by Auto so applying a visible fixed-route tier is still
196 /// recognized as an intentional picker choice.
197 initial_effort: ReasoningEffort,
198 /// Working raw preference. Model-row navigation only changes how this is
199 /// projected into the visible route-specific effort rows.
200 selected_effort_request: ReasoningEffort,
201 active_accepts_custom_model_ids: bool,
202 query: String,
203 /// Working selection (separate from the initial values so we can offer a
204 /// clean Esc-to-cancel without mutating App state).
205 selected_model_idx: usize,
206 selected_effort_idx: usize,
207 focus: Pane,
208 /// True when the active model is one we don't list — we still show it
209 /// so the picker doesn't quietly forget the user's chosen IDs.
210 show_custom_model_row: bool,
211 model_rows: Vec<ModelPickerRow>,
212 /// Static route facts used to validate custom/current rows at apply time.
213 route_config: Config,
214 /// Session-local provider checks used by custom/current rows. Catalog rows
215 /// resolve the same snapshot during construction.
216 provider_health: crate::provider_readiness::ProviderReadinessSnapshot,
217 view: ModelListView,
218 /// Other providers considered "configured" (#3830), shown by default
219 /// alongside `initial_provider`'s own rows without requiring the user to
220 /// type a search query first. Uses the same definition as the
221 /// `/provider` manager's default view
222 /// (`crate::config::provider_is_configured_for_active`): active
223 /// provider, working credentials/OAuth, or an explicit
224 /// `[providers.<name>]` entry. Self-hosted providers (Ollama/Sglang/
225 /// Vllm) don't qualify just because routing to them doesn't require a
226 /// key.
227 configured_providers: Vec<ApiProvider>,
228 row_hitboxes: RefCell<Vec<(Rect, Pane, usize)>>,
229 last_mouse_selected: Option<(Pane, usize)>,
230 /// UI locale captured from the app at construction (#4057 wave 2).
231 locale: Locale,
232 pinned_models: Vec<PinnedModel>,
233 }
234
235 #[derive(Debug, Clone, PartialEq, Eq)]
236 struct ModelPickerRow {
237 id: String,
238 provider: Option<ApiProvider>,
239 /// Concrete persistence identity. `Custom` alone cannot identify a named
240 /// custom route, so pins must carry this exact key when present.
241 provider_identity: Option<String>,
242 hint: String,
243 metadata: EffectivePickerMetadata,
244 selectable: bool,
245 /// Why this route cannot be attempted, kept structured so the scannable
246 /// row can show the reason without re-parsing the prose `hint`. `None`
247 /// whenever the route is attemptable.
248 blocked_reason: Option<String>,
249 /// Whether this provider/model pair belongs in the conservative ordinary
250 /// chooser. Explicit catalog views ignore this flag.
251 enabled: bool,
252 }
253
254 #[derive(Debug, Clone, PartialEq, Eq, Default)]
255 struct EffectivePickerMetadata {
256 context_window: Option<u32>,
257 max_output: Option<u32>,
258 tool_calls: Option<bool>,
259 reasoning: bool,
260 vision: SupportState,
261 pricing: PickerPricing,
262 source: Option<CatalogSource>,
263 }
264
265 #[derive(Debug, Clone, PartialEq, Eq, Default)]
266 enum PickerPricing {
267 /// The route explicitly does not expose authoritative token pricing.
268 Unavailable,
269 Known(String),
270 #[default]
271 Unknown,
272 }
273
274 impl ModelPickerView {
275 #[must_use]
276 pub fn new(app: &App, config: &Config) -> Self {
277 let initial_model = if app.auto_model {
278 "auto".to_string()
279 } else {
280 picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids())
281 .to_string()
282 };
283 let previous_model = if app.auto_model {
284 "auto".to_string()
285 } else {
286 app.model.clone()
287 };
288 let model_rows = picker_model_rows_for_app(app, config);
289 let configured_providers: Vec<_> = configured_providers(config, app.api_provider)
290 .into_iter()
291 .filter(|provider| *provider != app.api_provider)
292 .collect();
293 let mut default_visible_rows: Vec<_> = model_rows
294 .iter()
295 .filter(|row| model_row_visible_in_view(row, ModelListView::Configured))
296 .collect();
297 // Selection indices must be calculated in the same order that the
298 // configured view renders. Pinned rows are sorted to the top by
299 // `visible_model_rows`; using the unsorted construction order here
300 // made the cursor land on a different row (or look unselected) after
301 // a pin reordered the list.
302 sort_model_rows_for_view(
303 &mut default_visible_rows,
304 ModelListView::Configured,
305 &app.pinned_models,
306 );
307 let mut selected_model_idx = default_visible_rows.iter().position(|row| {
308 row.id == initial_model
309 && (row.provider.is_none() || row.provider == Some(app.api_provider))
310 });
311 let show_custom_model_row = selected_model_idx.is_none();
312 if show_custom_model_row {
313 selected_model_idx = Some(default_visible_rows.len());
314 }
315 let selected_model_idx = selected_model_idx.unwrap_or(0);
316
317 let initial_effort = app
318 .reasoning_effort_preference
319 .unwrap_or(ReasoningEffort::Auto);
320 let selected_effort_request = app
321 .reasoning_effort_preference
322 .unwrap_or(app.reasoning_effort);
323 let effort_rows = picker_efforts_for_route(
324 app.api_provider,
325 &config.deepseek_base_url(),
326 &initial_model,
327 app.auto_model,
328 );
329 let normalized = normalize_picker_effort(
330 selected_effort_request,
331 app.api_provider,
332 &config.deepseek_base_url(),
333 &initial_model,
334 app.auto_model,
335 );
336 let selected_effort_idx = effort_rows
337 .iter()
338 .position(|e| *e == normalized)
339 .unwrap_or_else(|| {
340 default_picker_effort_idx(
341 app.api_provider,
342 &config.deepseek_base_url(),
343 &initial_model,
344 app.auto_model,
345 )
346 });
347
348 let mut view = Self {
349 initial_model,
350 previous_model,
351 initial_provider: app.api_provider,
352 initial_effort,
353 selected_effort_request,
354 active_accepts_custom_model_ids: app.accepts_custom_model_ids(),
355 query: String::new(),
356 selected_model_idx,
357 selected_effort_idx,
358 focus: Pane::Model,
359 show_custom_model_row,
360 model_rows,
361 route_config: config.clone(),
362 provider_health: app.provider_health.clone(),
363 view: ModelListView::Configured,
364 configured_providers,
365 row_hitboxes: RefCell::new(Vec::new()),
366 last_mouse_selected: None,
367 locale: app.ui_locale,
368 pinned_models: app.pinned_models.clone(),
369 };
370 view.restore_memory(app.model_picker_memory.as_ref());
371 view
372 }
373
374 /// Restore the browsing context from the last dismissed picker (#4109):
375 /// the named catalog view and, when the remembered row still exists in
376 /// that view, the highlighted row. The active model remains the selection
377 /// when nothing was remembered or the row is gone.
378 fn restore_memory(&mut self, memory: Option<&crate::tui::app::ModelPickerMemory>) {
379 let Some(memory) = memory else {
380 return;
381 };
382 if let Some(view_name) = memory.view.as_deref() {
383 if let Some(view) = ModelListView::from_memory_name(view_name) {
384 self.view = view;
385 }
386 } else if memory.catalog_view {
387 self.view = ModelListView::Catalog;
388 }
389 if let Some(remembered_id) = memory.selected_row_id.as_deref() {
390 let position = self
391 .visible_model_rows()
392 .iter()
393 .position(|row| row.id == remembered_id);
394 if let Some(position) = position {
395 self.selected_model_idx = position;
396 self.select_effort_for_current_model();
397 }
398 }
399 self.clamp_model_selection();
400 }
401
402 #[cfg(test)]
403 fn visible_model_ids(&self) -> Vec<&str> {
404 self.visible_model_rows()
405 .iter()
406 .map(|row| row.id.as_str())
407 .collect()
408 }
409
410 fn visible_model_rows(&self) -> Vec<&ModelPickerRow> {
411 let query = self.query.trim();
412 let mut rows: Vec<&ModelPickerRow> = self
413 .model_rows
414 .iter()
415 .filter(|row| {
416 if query.is_empty() {
417 // Empty query: view scope only (Configured stays conservative).
418 model_row_visible_in_view(row, self.view)
419 } else {
420 // Typed filter searches the full lake so cross-provider
421 // routes remain discoverable without leaving Configured.
422 model_row_matches_query(row, query, self.initial_provider)
423 }
424 })
425 .collect();
426 if query.is_empty() {
427 sort_model_rows_for_view(&mut rows, self.view, &self.pinned_models);
428 } else {
429 // Rank typed results (#4639): rows whose provider matches the
430 // query first (provider drill-down), then exact/prefix id
431 // matches, then the active provider's rows, then alphabetical —
432 // so a provider-heavy catalog (e.g. OpenRouter) surfaces the
433 // intended route in the first few rows, not raw catalog order.
434 let query_lower = query.to_ascii_lowercase();
435 let initial_provider = self.initial_provider;
436 rows.sort_by(|a, b| {
437 let rank = |row: &ModelPickerRow| {
438 let provider_matches = row.provider.is_some_and(|provider| {
439 row.provider_identity.as_deref().is_some_and(|identity| {
440 identity.to_ascii_lowercase().contains(&query_lower)
441 }) || provider
442 .as_str()
443 .to_ascii_lowercase()
444 .contains(&query_lower)
445 || provider
446 .display_name()
447 .to_ascii_lowercase()
448 .contains(&query_lower)
449 });
450 let id = row.id.to_ascii_lowercase();
451 let id_rank = if id == query_lower {
452 0
453 } else if id.starts_with(&query_lower) {
454 1
455 } else {
456 2
457 };
458 let provider_rank =
459 if row.provider.is_none() || row.provider == Some(initial_provider) {
460 0
461 } else {
462 1
463 };
464 (
465 if provider_matches { 0 } else { 1 },
466 id_rank,
467 provider_rank,
468 id,
469 )
470 };
471 rank(a).cmp(&rank(b))
472 });
473 }
474 rows
475 }
476
477 fn model_row_count(&self) -> usize {
478 let rows = self.visible_model_rows();
479 rows.len() + usize::from(self.custom_model_row_for_visible(&rows).is_some())
480 }
481
482 /// Resolve the currently highlighted row to a model id.
483 fn resolved_model(&self) -> String {
484 let rows = self.visible_model_rows();
485 if self.selected_model_idx < rows.len() {
486 return rows[self.selected_model_idx].id.clone();
487 }
488 self.custom_model_row()
489 .map(|(model, _)| model)
490 .unwrap_or_else(|| self.initial_model.clone())
491 }
492
493 fn selected_model_is_selectable(&self) -> bool {
494 let rows = self.visible_model_rows();
495 if let Some(row) = rows.get(self.selected_model_idx) {
496 return row.selectable;
497 }
498 self.custom_model_row().is_some_and(|(model, provider)| {
499 crate::provider_readiness::resolve_for_model(
500 &self.route_config,
501 provider,
502 &model,
503 &self.provider_health,
504 )
505 .can_attempt()
506 })
507 }
508
509 /// Feedback when Enter/apply is pressed on a locked (unauthenticated) model.
510 /// Surfaces the readiness reason instead of a silent no-op, and routes the
511 /// user toward provider authentication/setup when possible.
512 fn explain_unselectable_selection(&self) -> ViewAction {
513 let rows = self.visible_model_rows();
514 let Some(row) = rows.get(self.selected_model_idx) else {
515 return ViewAction::None;
516 };
517 let reason = if row.hint.trim().is_empty() {
518 "This model is not available with the current provider credentials.".to_string()
519 } else {
520 row.hint.clone()
521 };
522 let message = format!(
523 "🔒 {} is locked — {reason}. Open /provider to authenticate, then refresh.",
524 row.id
525 );
526 // Prefer opening provider setup so the user can remediate in one step.
527 if let Some(provider) = row.provider {
528 return ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth {
529 provider,
530 model: row.id.clone(),
531 reason: message,
532 });
533 }
534 ViewAction::Emit(ViewEvent::StatusMessage { message })
535 }
536
537 fn resolved_provider(&self) -> Option<ApiProvider> {
538 let rows = self.visible_model_rows();
539 if self.selected_model_idx < rows.len() {
540 return rows[self.selected_model_idx].provider;
541 }
542 self.custom_model_row()
543 .map(|(_, provider)| provider)
544 .or(Some(self.initial_provider))
545 }
546
547 fn resolved_effort(&self) -> ReasoningEffort {
548 let efforts = self.current_efforts();
549 efforts[self
550 .selected_effort_idx
551 .min(efforts.len().saturating_sub(1))]
552 }
553
554 fn current_efforts(&self) -> Vec<ReasoningEffort> {
555 let provider = self.resolved_provider().unwrap_or(self.initial_provider);
556 let model = self.resolved_model();
557 let base_url = self.resolved_base_url_for_provider(provider, &model);
558 picker_efforts_for_route(
559 provider,
560 &base_url,
561 &model,
562 model.trim().eq_ignore_ascii_case("auto"),
563 )
564 }
565
566 fn resolved_base_url_for_provider(&self, provider: ApiProvider, model: &str) -> String {
567 crate::route_runtime::resolve_runtime_route(&self.route_config, provider, Some(model))
568 .map(|route| route.candidate.endpoint().base_url.clone())
569 .unwrap_or_else(|_| provider.default_base_url().to_string())
570 }
571
572 fn custom_model_row(&self) -> Option<(String, ApiProvider)> {
573 let rows = self.visible_model_rows();
574 self.custom_model_row_for_visible(&rows)
575 }
576
577 fn custom_model_row_for_visible(
578 &self,
579 visible_rows: &[&ModelPickerRow],
580 ) -> Option<(String, ApiProvider)> {
581 let query = self.query.trim();
582 if query.is_empty() {
583 return self
584 .show_custom_model_row
585 .then(|| (self.initial_model.clone(), self.initial_provider));
586 }
587 if let Some((provider, model)) = self.provider_qualified_custom_query(query) {
588 if visible_rows.iter().any(|row| {
589 row.provider == Some(provider) && row.id.eq_ignore_ascii_case(model.trim())
590 }) {
591 return None;
592 }
593 if self.provider_accepts_custom_model(provider, &model) {
594 return Some((model, provider));
595 }
596 return None;
597 }
598 if !self.active_accepts_custom_model_ids {
599 return None;
600 }
601 if visible_rows.iter().any(|row| {
602 row.provider == Some(self.initial_provider) && row.id.eq_ignore_ascii_case(query)
603 }) {
604 return None;
605 }
606 Some((query.to_string(), self.initial_provider))
607 }
608
609 fn provider_qualified_custom_query(&self, query: &str) -> Option<(ApiProvider, String)> {
610 for (provider_key, model) in provider_query_splits(query) {
611 let Some(provider) = ApiProvider::parse(provider_key) else {
612 continue;
613 };
614 if provider != self.initial_provider
615 && !self.view.browses_all_providers()
616 && !self.configured_providers.contains(&provider)
617 {
618 continue;
619 }
620 let model = model.trim();
621 if model.is_empty() {
622 continue;
623 }
624 return Some((provider, model.to_string()));
625 }
626 None
627 }
628
629 fn provider_accepts_custom_model(&self, provider: ApiProvider, model: &str) -> bool {
630 (provider == self.initial_provider && self.active_accepts_custom_model_ids)
631 || crate::config::normalize_model_name_for_provider(provider, model).is_some()
632 }
633
634 fn clamp_model_selection(&mut self) {
635 let count = self.model_row_count();
636 if count == 0 {
637 self.selected_model_idx = 0;
638 } else if self.selected_model_idx >= count {
639 self.selected_model_idx = count - 1;
640 }
641 }
642
643 fn update_query(&mut self, next: String) {
644 self.query = next;
645 self.selected_model_idx = 0;
646 self.clamp_model_selection();
647 self.select_effort_for_current_model();
648 }
649
650 fn select_effort_for_current_model(&mut self) {
651 let provider = self.resolved_provider().unwrap_or(self.initial_provider);
652 let model = self.resolved_model();
653 let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
654 let base_url = self.resolved_base_url_for_provider(provider, &model);
655 let normalized = normalize_picker_effort(
656 self.selected_effort_request,
657 provider,
658 &base_url,
659 &model,
660 model_is_auto,
661 );
662 self.selected_effort_idx =
663 picker_efforts_for_route(provider, &base_url, &model, model_is_auto)
664 .iter()
665 .position(|candidate| *candidate == normalized)
666 .unwrap_or_else(|| {
667 default_picker_effort_idx(provider, &base_url, &model, model_is_auto)
668 });
669 }
670
671 fn move_up(&mut self) -> bool {
672 match self.focus {
673 Pane::Model => {
674 if self.selected_model_idx > 0 {
675 self.selected_model_idx -= 1;
676 self.select_effort_for_current_model();
677 return true;
678 }
679 }
680 Pane::Effort => {
681 if self.selected_effort_idx > 0 {
682 self.selected_effort_idx -= 1;
683 self.selected_effort_request = self.resolved_effort();
684 return true;
685 }
686 }
687 }
688 false
689 }
690
691 fn move_down(&mut self) -> bool {
692 match self.focus {
693 Pane::Model => {
694 let max = self.model_row_count().saturating_sub(1);
695 if self.selected_model_idx < max {
696 self.selected_model_idx += 1;
697 self.select_effort_for_current_model();
698 return true;
699 }
700 }
701 Pane::Effort => {
702 let max = self.current_efforts().len().saturating_sub(1);
703 if self.selected_effort_idx < max {
704 self.selected_effort_idx += 1;
705 self.selected_effort_request = self.resolved_effort();
706 return true;
707 }
708 }
709 }
710 false
711 }
712
713 fn toggle_focus(&mut self) {
714 self.focus = match self.focus {
715 Pane::Model => Pane::Effort,
716 Pane::Effort => Pane::Model,
717 };
718 }
719
720 fn toggle_view(&mut self) {
721 self.view = self.view.next();
722 self.selected_model_idx = 0;
723 self.clamp_model_selection();
724 self.select_effort_for_current_model();
725 }
726
727 fn build_event(&self) -> ViewEvent {
728 self.build_event_with_startup_default(false)
729 }
730
731 fn build_event_with_startup_default(&self, save_as_startup_default: bool) -> ViewEvent {
732 let resolved_provider = self.resolved_provider().unwrap_or(self.initial_provider);
733 let provider = (resolved_provider != self.initial_provider).then_some(resolved_provider);
734 let provider_id = (resolved_provider == ApiProvider::Custom)
735 .then(|| self.route_config.provider_identity_for(resolved_provider));
736 ViewEvent::ModelPickerApplied {
737 model: self.resolved_model(),
738 provider,
739 provider_id,
740 effort: self.selected_effort_request,
741 previous_model: self.previous_model.clone(),
742 previous_effort: self.initial_effort,
743 save_as_startup_default,
744 }
745 }
746
747 fn render_pane(
748 &self,
749 area: Rect,
750 buf: &mut Buffer,
751 title: &str,
752 rows: Vec<PaneRow>,
753 state: PaneRenderState,
754 ) {
755 let visible_height = usize::from(area.height.saturating_sub(1));
756 let (start, end) = visible_row_window(state.selected, rows.len(), visible_height);
757 let title = if rows.len() > visible_height && visible_height > 0 {
758 if start + 1 == end {
759 // A scrollable pane whose visible window spans exactly one row
760 // renders a single position (`Model 2/3`), not a degenerate
761 // `2-2/3` range (#3995).
762 format!(" {title} {}/{} ", end, rows.len())
763 } else {
764 format!(" {title} {}-{}/{} ", start + 1, end, rows.len())
765 }
766 } else {
767 format!(" {title} ")
768 };
769 Block::default()
770 .style(Style::default().bg(palette::WHALE_BG))
771 .render(area, buf);
772 let title_area = Rect { height: 1, ..area };
773 Paragraph::new(Line::from(vec![
774 Span::styled(
775 if state.focused { "▸ " } else { " " },
776 Style::default().fg(palette::WHALE_INFO),
777 ),
778 Span::styled(
779 title,
780 Style::default()
781 .fg(if state.focused {
782 palette::WHALE_INFO
783 } else {
784 palette::TEXT_PRIMARY
785 })
786 .bold(),
787 ),
788 ]))
789 .render(title_area, buf);
790 let inner = Rect {
791 y: area.y.saturating_add(1),
792 height: area.height.saturating_sub(1),
793 ..area
794 };
795
796 // Column widths are measured over the rows actually on screen, so the
797 // route column lands at one predictable offset for the whole page
798 // instead of drifting with whatever long id happens to be scrolled in.
799 let columns = ModelRowColumns::for_page(&rows[start.min(rows.len())..end.min(rows.len())]);
800
801 let mut lines = Vec::with_capacity(end.saturating_sub(start));
802 let pane_height = usize::from(inner.height);
803 for (idx, row) in rows.iter().enumerate().skip(start).take(end - start) {
804 // Family headers consume pane lines too: stop building (and stop
805 // recording hitboxes) as soon as the pane is full, so rendering
806 // never addresses the buffer past its bounds.
807 if lines.len() >= pane_height {
808 break;
809 }
810 let is_selected = idx == state.selected;
811 // Non-selectable rows are dimmed with a lock glyph so they never
812 // look choosable. Selection still highlights, but stays muted.
813 let locked = state.pane == Pane::Model
814 && self
815 .visible_model_rows()
816 .get(idx)
817 .is_some_and(|row| !row.selectable);
818 // Marker precedence: a locked route first (it is the reason Enter
819 // will not work), then the keyboard cursor, then the route this
820 // session is already on. `CURRENT` is the charter's "current human
821 // choice" mark, so "which one am I on?" is answered by shape rather
822 // than by a second accent colour.
823 let marker = if locked {
824 "🔒"
825 } else if is_selected {
826 crate::tui::glyphs::SELECTION
827 } else if row.active {
828 crate::tui::glyphs::CURRENT
829 } else {
830 " "
831 };
832 let label_style = if is_selected && !locked {
833 menu_style::selected_row_style()
834 } else if is_selected && locked {
835 menu_style::disabled_selected_row_style()
836 } else if locked {
837 Style::default()
838 .fg(palette::TEXT_MUTED)
839 .add_modifier(Modifier::DIM)
840 } else {
841 Style::default().fg(palette::TEXT_PRIMARY)
842 };
843 let hint_style = if is_selected && !locked {
844 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
845 } else {
846 Style::default().fg(palette::TEXT_MUTED)
847 };
848 // Provider → family → model grouping: a dim family header is
849 // drawn when the catalog states a family and it differs from the
850 // previous visible row's (families sort contiguously). Unknown
851 // families draw nothing.
852 if let Some(family) = row.family.as_deref() {
853 let prev_family = rows
854 .get(idx.wrapping_sub(1))
855 .and_then(|prev| prev.family.as_deref());
856 let prev_provider = rows
857 .get(idx.wrapping_sub(1))
858 .map(|prev| prev.route.as_str());
859 if prev_family != Some(family) || prev_provider != Some(row.route.as_str()) {
860 lines.push(Line::from(Span::styled(
861 format!(" ─ {family}"),
862 Style::default().fg(palette::TEXT_DIM),
863 )));
864 }
865 }
866 // The hitbox points at the row's own line (after any family
867 // header), so mouse/scan targets and keyboard targets agree.
868 let row_y = inner.y.saturating_add(lines.len() as u16);
869 self.row_hitboxes.borrow_mut().push((
870 Rect::new(inner.x, row_y, inner.width, 1),
871 state.pane,
872 idx,
873 ));
874 let spans = picker_row_spans(
875 row,
876 marker,
877 usize::from(inner.width),
878 columns,
879 label_style,
880 hint_style,
881 );
882 lines.push(Line::from(spans));
883 }
884 if rows.is_empty() {
885 // A search that matches nothing must say so, not render a bare
886 // empty box (#3757 UX review).
887 let message = if self.query.is_empty() {
888 tr(self.locale, MessageId::RouteNoModels).into_owned()
889 } else {
890 tr(self.locale, MessageId::RouteNoModelMatch).replace("{query}", &self.query)
891 };
892 lines.push(Line::from(Span::styled(
893 message,
894 Style::default().fg(palette::TEXT_MUTED),
895 )));
896 }
897 // Family headers can push the visible rows past the viewport; clip
898 // to the area so rendering never indexes the buffer out of bounds
899 // (ratatui-core 0.1.0 panics instead of clipping).
900 if lines.len() > usize::from(inner.height) {
901 lines.truncate(usize::from(inner.height));
902 }
903 Paragraph::new(lines).render(inner, buf);
904 }
905 }
906
907 fn visible_row_window(selected: usize, total: usize, viewport_height: usize) -> (usize, usize) {
908 if total == 0 || viewport_height == 0 {
909 return (0, 0);
910 }
911
912 let visible = viewport_height.min(total);
913 let mut start = selected.saturating_sub(visible / 2);
914 if start + visible > total {
915 start = total.saturating_sub(visible);
916 }
917 (start, start + visible)
918 }
919
920 /// Widest Thinking row plus its marker: `max (extra-high reasoning)`.
921 const EFFORT_PANE_WIDTH: u16 = 30;
922
923 /// Give the model list the width the Thinking pane cannot use.
924 ///
925 /// The generic list/detail split caps the list at 52 columns and hands the
926 /// remainder to the detail pane. Thinking rows are a fixed, short vocabulary,
927 /// so on a wide terminal most of the row went to a pane with nothing to put
928 /// there while the model rows — which carry the id, route and metadata that
929 /// tell near-identical routes apart — were squeezed into half a screen.
930 fn widen_model_pane(layout: ListDetailLayout) -> ListDetailLayout {
931 if layout.stacked {
932 return layout;
933 }
934 let gap = layout
935 .detail
936 .x
937 .saturating_sub(layout.list.x.saturating_add(layout.list.width));
938 let total = layout.list.width + gap + layout.detail.width;
939 let detail_width = layout.detail.width.min(EFFORT_PANE_WIDTH);
940 let list_width = total.saturating_sub(gap + detail_width);
941 ListDetailLayout {
942 list: Rect {
943 width: list_width,
944 ..layout.list
945 },
946 detail: Rect {
947 x: layout.list.x + list_width + gap,
948 width: detail_width,
949 ..layout.detail
950 },
951 stacked: false,
952 }
953 }
954
955 /// One rendered row in either picker pane, split into the columns the row is
956 /// laid out from.
957 ///
958 /// Model rows fill all three: the wire id (`primary`), the route identity that
959 /// separates same-named models on different endpoints (`route`), and the facts
960 /// that actually vary between neighbouring rows (`meta`). Thinking-effort rows
961 /// leave `route` empty and keep their descriptive `meta`.
962 #[derive(Debug, Clone, Default, PartialEq, Eq)]
963 struct PaneRow {
964 primary: String,
965 route: String,
966 /// Metadata as separable units. Kept as a list so a squeezed column sheds
967 /// whole facts instead of rendering half a word.
968 meta: Vec<String>,
969 /// Catalog model family (e.g. `deepseek`, `glm`) for section headers.
970 /// None = the catalog did not state a family (no header is drawn).
971 family: Option<String>,
972 /// The route this session is already on.
973 active: bool,
974 }
975
976 impl PaneRow {
977 fn effort(primary: String, meta: String) -> Self {
978 Self {
979 primary,
980 route: String::new(),
981 meta: if meta.is_empty() {
982 Vec::new()
983 } else {
984 vec![meta]
985 },
986 family: None,
987 active: false,
988 }
989 }
990
991 fn meta_width(&self) -> usize {
992 unicode_width::UnicodeWidthStr::width(self.meta.join(" · ").as_str())
993 }
994 }
995
996 /// Per-page column offsets for a picker pane.
997 ///
998 /// Rows used to render as `label (one long parenthesised hint)`, which meant
999 /// the hint was dropped whole whenever it did not fit — and at every real
1000 /// terminal width it never fit, so a dozen DeepSeek routes all rendered as
1001 /// nothing but their near-identical ids. Fixed columns fix that: each field
1002 /// gets a measured share of the row and is truncated on its own, so the
1003 /// distinguishing token is always on screen at a predictable offset.
1004 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1005 struct ModelRowColumns {
1006 primary: usize,
1007 route: usize,
1008 meta: usize,
1009 }
1010
1011 /// Width reserved for the marker glyph itself. The lock is a two-column emoji
1012 /// while `▸` and `●` are one, so the cell is padded to the widest of them —
1013 /// otherwise a single locked row shifts every column on its line by one.
1014 const MARKER_CELL_WIDTH: usize = 2;
1015 /// ` ▸ ` — one leading space, the marker cell, one trailing space.
1016 const ROW_PREFIX_WIDTH: usize = MARKER_CELL_WIDTH + 2;
1017 /// Blank cells between two columns.
1018 const COLUMN_GAP: usize = 2;
1019 /// Below this a route column tells the user nothing, so the space goes to the
1020 /// id instead.
1021 const MIN_ROUTE_WIDTH: usize = 6;
1022 /// Below this the metadata column cannot hold even a context-window token.
1023 const MIN_META_WIDTH: usize = 4;
1024
1025 impl ModelRowColumns {
1026 /// Measure the natural width each column wants, over the rows on screen.
1027 fn for_page(rows: &[PaneRow]) -> Self {
1028 let widest = |pick: fn(&PaneRow) -> usize| rows.iter().map(pick).max().unwrap_or(0);
1029 Self {
1030 primary: widest(|row| unicode_width::UnicodeWidthStr::width(row.primary.as_str())),
1031 route: widest(|row| unicode_width::UnicodeWidthStr::width(row.route.as_str())),
1032 meta: widest(PaneRow::meta_width),
1033 }
1034 }
1035
1036 /// Fit the measured widths into the width actually available.
1037 ///
1038 /// When everything fits, every column keeps its natural width. When it does
1039 /// not, the scarce space is divided rather than handed to whichever column
1040 /// comes first: the id used to take everything and the metadata was dropped
1041 /// whole, which is precisely how a dozen near-identical routes ended up
1042 /// rendering as nothing but their shared prefix.
1043 fn resolve(self, width: usize) -> Self {
1044 let available = width.saturating_sub(ROW_PREFIX_WIDTH);
1045 if available == 0 {
1046 return Self::default();
1047 }
1048 let gaps = COLUMN_GAP * (usize::from(self.route > 0) + usize::from(self.meta > 0));
1049 let content = available.saturating_sub(gaps);
1050 if content == 0 {
1051 return Self {
1052 primary: available,
1053 route: 0,
1054 meta: 0,
1055 };
1056 }
1057 if self.primary + self.route + self.meta <= content {
1058 return self;
1059 }
1060 // With no route column there is nothing to protect from a long id, so
1061 // the id keeps its natural width and the trailing metadata yields — a
1062 // clipped model id is worse than a hidden hint.
1063 if self.route == 0 {
1064 let primary = self.primary.min(content);
1065 let meta = self.meta.min(content.saturating_sub(primary));
1066 return Self {
1067 primary,
1068 route: 0,
1069 meta: if meta < MIN_META_WIDTH { 0 } else { meta },
1070 };
1071 }
1072
1073 // Floors first, so no column that has something to say disappears
1074 // entirely; then each takes the smaller of its natural width and its
1075 // share. Metadata is the densest per column and gets the tightest cap.
1076 let mut meta = if self.meta == 0 {
1077 0
1078 } else {
1079 self.meta
1080 .min((content / 4).max(MIN_META_WIDTH.min(content)))
1081 };
1082 let after_meta = content.saturating_sub(meta);
1083 let mut route = if self.route == 0 {
1084 0
1085 } else {
1086 self.route
1087 .min((after_meta / 3).max(MIN_ROUTE_WIDTH.min(after_meta)))
1088 };
1089 let mut primary = after_meta.saturating_sub(route);
1090
1091 // The id's share is whatever the other two did not take, which can
1092 // exceed the longest id on the page. Hand that surplus back rather than
1093 // padding blank space next to a metadata column that is shedding facts.
1094 if primary > self.primary {
1095 let mut slack = primary - self.primary;
1096 primary = self.primary;
1097 for (column, natural) in [(&mut meta, self.meta), (&mut route, self.route)] {
1098 let gain = slack.min(natural.saturating_sub(*column));
1099 *column += gain;
1100 slack -= gain;
1101 }
1102 primary += slack;
1103 }
1104
1105 Self {
1106 primary,
1107 route,
1108 meta,
1109 }
1110 }
1111 }
1112
1113 /// Truncate an identifier from the middle, keeping both ends.
1114 ///
1115 /// Model ids and route names share their heads and differ in their tails:
1116 /// `deepseek-ai/DeepSeek-V4-Pro` and `deepseek-ai/DeepSeek-V4-Flash` are
1117 /// identical for twenty characters and only separate at the very end. Clipping
1118 /// the tail therefore deletes the one token that tells them apart — both rows
1119 /// render as `deepseek-ai/DeepSee...`. Keeping a slice of each end costs one
1120 /// column for the ellipsis and preserves the variant.
1121 fn fit_identifier(text: &str, width: usize) -> String {
1122 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1123
1124 if UnicodeWidthStr::width(text) <= width {
1125 return text.to_string();
1126 }
1127 // Too narrow to seat a head, an ellipsis and a meaningful tail; fall back
1128 // to the plain head-first form rather than emit punctuation soup.
1129 if width < 8 {
1130 return fit_text(text, width);
1131 }
1132
1133 let budget = width - 1;
1134 // The tail is the discriminating end, so it gets the larger share.
1135 let tail_budget = (budget * 3) / 5;
1136 let head_budget = budget - tail_budget;
1137
1138 let mut head = String::new();
1139 let mut used = 0usize;
1140 for ch in text.chars() {
1141 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1142 if used + ch_width > head_budget {
1143 break;
1144 }
1145 used += ch_width;
1146 head.push(ch);
1147 }
1148
1149 let mut tail: Vec<char> = Vec::new();
1150 let mut used = 0usize;
1151 for ch in text.chars().rev() {
1152 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1153 if used + ch_width > tail_budget {
1154 break;
1155 }
1156 used += ch_width;
1157 tail.push(ch);
1158 }
1159 tail.reverse();
1160
1161 let mut out = head;
1162 out.push('…');
1163 out.extend(tail);
1164 out
1165 }
1166
1167 /// Lay a row out into aligned, individually-truncated columns.
1168 ///
1169 /// Colour vocabulary is deliberately two-valued: `label_style` for the row's
1170 /// primary content and `hint_style` for every secondary column. Selection is
1171 /// the only thing that changes a row's colour.
1172 fn picker_row_spans<'a>(
1173 row: &'a PaneRow,
1174 marker: &'static str,
1175 width: usize,
1176 columns: ModelRowColumns,
1177 label_style: Style,
1178 hint_style: Style,
1179 ) -> Vec<Span<'a>> {
1180 use unicode_width::UnicodeWidthStr;
1181
1182 let columns = columns.resolve(width);
1183 let marker_pad = MARKER_CELL_WIDTH.saturating_sub(UnicodeWidthStr::width(marker));
1184 let mut spans = vec![
1185 Span::styled(" ", label_style),
1186 Span::styled(marker, label_style),
1187 Span::styled(" ".repeat(marker_pad + 1), label_style),
1188 ];
1189 let mut used = ROW_PREFIX_WIDTH;
1190
1191 let primary = fit_identifier(&row.primary, columns.primary.max(1));
1192 used += UnicodeWidthStr::width(primary.as_str());
1193 spans.push(Span::styled(primary, label_style));
1194
1195 // Pad to the column edge only when something follows; a trailing run of
1196 // spaces would otherwise extend the selected row's highlight past its text.
1197 let pad_to = |spans: &mut Vec<Span<'a>>, used: &mut usize, target: usize| {
1198 if *used < target {
1199 spans.push(Span::styled(" ".repeat(target - *used), label_style));
1200 *used = target;
1201 }
1202 };
1203
1204 if columns.route > 0 && !row.route.is_empty() {
1205 pad_to(&mut spans, &mut used, ROW_PREFIX_WIDTH + columns.primary);
1206 spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
1207 used += COLUMN_GAP;
1208 let route = fit_identifier(&row.route, columns.route);
1209 used += UnicodeWidthStr::width(route.as_str());
1210 spans.push(Span::styled(route, hint_style));
1211 }
1212
1213 if !row.meta.is_empty() {
1214 let column_edge = if columns.route > 0 && !row.route.is_empty() {
1215 ROW_PREFIX_WIDTH + columns.primary + COLUMN_GAP + columns.route
1216 } else {
1217 ROW_PREFIX_WIDTH + columns.primary
1218 };
1219 // Take the smaller of the column's share and the physical remainder, so
1220 // a row that ended early cannot overrun the pane.
1221 let remaining = width
1222 .saturating_sub(column_edge)
1223 .saturating_sub(COLUMN_GAP)
1224 .min(columns.meta.max(MIN_META_WIDTH));
1225 let meta = fit_meta_chips(&row.meta, remaining);
1226 if !meta.is_empty() {
1227 pad_to(&mut spans, &mut used, column_edge);
1228 spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
1229 spans.push(Span::styled(meta, hint_style));
1230 }
1231 }
1232
1233 spans
1234 }
1235
1236 fn fit_text(text: &str, width: usize) -> String {
1237 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1238
1239 if UnicodeWidthStr::width(text) <= width {
1240 return text.to_string();
1241 }
1242 if width == 0 {
1243 return String::new();
1244 }
1245 if width <= 3 {
1246 return ".".repeat(width);
1247 }
1248
1249 let mut out = String::new();
1250 let target = width - 3;
1251 let mut used = 0usize;
1252 for ch in text.chars() {
1253 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1254 if used + ch_width > target {
1255 break;
1256 }
1257 used += ch_width;
1258 out.push(ch);
1259 }
1260 out.push_str("...");
1261 out
1262 }
1263
1264 #[cfg(test)]
1265 fn picker_model_ids_for_provider(provider: ApiProvider) -> Vec<String> {
1266 let mut models = vec!["auto".to_string()];
1267 for id in provider_catalog_model_ids(provider) {
1268 if id != "auto" && !models.iter().any(|m| m.eq_ignore_ascii_case(&id)) {
1269 models.push(id);
1270 }
1271 }
1272 models
1273 }
1274
1275 pub(crate) fn provider_scoped_model_completion_ids(app: &App) -> Vec<String> {
1276 // Slash completions inline the current custom model so `/model <current>`
1277 // stays visible even when it is outside the provider catalog.
1278 provider_scoped_model_ids_for_app(app, true)
1279 }
1280
1281 fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow> {
1282 let mut rows = Vec::new();
1283 let auto_hint = auto_picker_hint(app, config);
1284 push_auto_model_row(&mut rows, app, config, &auto_hint);
1285 // One snapshot supplies both IDs, capabilities, and freshness so a cache
1286 // replacement cannot produce mixed-generation picker rows.
1287 let codex_roster = codex_model_cache::model_roster();
1288 let mut active_model_ids = if app.api_provider == ApiProvider::OpenaiCodex {
1289 let mut models = vec!["auto".to_string()];
1290 for id in codex_roster.model_ids() {
1291 push_model_id(&mut models, &id);
1292 }
1293 if let Some(model) = app
1294 .provider_models
1295 .get(app.provider_identity_for_persistence())
1296 .map(|model| model.trim())
1297 .filter(|model| !model.is_empty())
1298 {
1299 push_model_id(
1300 &mut models,
1301 picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
1302 );
1303 }
1304 models
1305 } else {
1306 provider_scoped_model_ids_for_app(app, false)
1307 };
1308 push_configured_provider_model(&mut active_model_ids, config, app.api_provider);
1309 push_provider_model_rows(
1310 &mut rows,
1311 app.api_provider,
1312 active_model_ids,
1313 app.api_provider,
1314 config,
1315 &codex_roster,
1316 &app.provider_health,
1317 );
1318
1319 for provider in ApiProvider::sorted_for_display() {
1320 if provider == app.api_provider {
1321 continue;
1322 }
1323 let mut model_ids = if provider == ApiProvider::OpenaiCodex {
1324 codex_roster.model_ids()
1325 } else {
1326 provider_catalog_model_ids(provider)
1327 };
1328 if let Some(model) = app
1329 .provider_models
1330 .get(provider.as_str())
1331 .map(|model| model.trim())
1332 .filter(|model| !model.is_empty())
1333 {
1334 push_model_id(
1335 &mut model_ids,
1336 picker_visible_model_id(
1337 provider,
1338 model,
1339 config.model_ids_pass_through_for_provider(provider),
1340 ),
1341 );
1342 }
1343 push_configured_provider_model(&mut model_ids, config, provider);
1344 push_provider_model_rows(
1345 &mut rows,
1346 provider,
1347 model_ids,
1348 app.api_provider,
1349 config,
1350 &codex_roster,
1351 &app.provider_health,
1352 );
1353 }
1354
1355 // `ApiProvider::Custom` is shared by every named custom route. Preserve
1356 // the concrete active route key on rows so exact pins cannot collide.
1357 let active_custom_identity = (app.api_provider == ApiProvider::Custom)
1358 .then(|| app.provider_identity_for_persistence().to_string());
1359 for row in &mut rows {
1360 if row.provider == Some(ApiProvider::Custom) {
1361 row.provider_identity = active_custom_identity.clone();
1362 }
1363 }
1364
1365 for row in &mut rows {
1366 row.enabled = model_row_enabled_for_app(app, config, row);
1367 if let Some(pin) = app.pinned_models.iter().find(|pin| {
1368 row_provider_identity(row)
1369 .is_some_and(|provider| provider.eq_ignore_ascii_case(&pin.provider))
1370 && row.id.eq_ignore_ascii_case(&pin.model)
1371 }) {
1372 let label = pin.label.as_deref().unwrap_or("pinned");
1373 row.hint = format!(
1374 "{label} · exact {} / {} · {}",
1375 pin.provider, pin.model, row.hint
1376 );
1377 }
1378 }
1379
1380 for pin in &app.pinned_models {
1381 let provider = ApiProvider::parse(&pin.provider).unwrap_or(ApiProvider::Custom);
1382 if rows.iter().any(|row| {
1383 row_provider_identity(row)
1384 .is_some_and(|identity| identity.eq_ignore_ascii_case(&pin.provider))
1385 && row.id.eq_ignore_ascii_case(&pin.model)
1386 }) {
1387 continue;
1388 }
1389 let metadata = effective_picker_metadata(config, Some(provider), &pin.model);
1390 // Bypass the ordinary `(enum provider, model)` de-duplication here:
1391 // two named Custom routes may intentionally expose the same model id.
1392 rows.push(ModelPickerRow {
1393 id: pin.model.clone(),
1394 provider: Some(provider),
1395 provider_identity: Some(pin.provider.clone()),
1396 hint: format!(
1397 "stale pinned · exact {} / {} · unavailable; repair or remove",
1398 pin.provider, pin.model
1399 ),
1400 metadata,
1401 selectable: false,
1402 blocked_reason: Some("stale pin".to_string()),
1403 enabled: true,
1404 });
1405 }
1406
1407 rows
1408 }
1409
1410 fn model_row_enabled_for_app(app: &App, config: &Config, row: &ModelPickerRow) -> bool {
1411 let Some(provider) = row.provider else {
1412 return true;
1413 };
1414 if provider == app.api_provider {
1415 let current =
1416 picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids());
1417 if row.id.eq_ignore_ascii_case(current) {
1418 return true;
1419 }
1420 }
1421 let provider_identity = if provider == app.api_provider {
1422 app.provider_identity_for_persistence()
1423 } else {
1424 provider.as_str()
1425 };
1426 if app.provider_model_is_enabled(provider_identity, &row.id)
1427 || app
1428 .provider_models
1429 .get(provider_identity)
1430 .is_some_and(|model| model.eq_ignore_ascii_case(&row.id))
1431 {
1432 return true;
1433 }
1434 config
1435 .provider_config_for(provider)
1436 .and_then(|entry| entry.model.as_deref())
1437 .is_some_and(|model| model.eq_ignore_ascii_case(&row.id))
1438 }
1439
1440 fn push_provider_model_rows(
1441 rows: &mut Vec<ModelPickerRow>,
1442 provider: ApiProvider,
1443 model_ids: Vec<String>,
1444 active_provider: ApiProvider,
1445 config: &Config,
1446 codex_roster: &CodexModelRoster,
1447 provider_health: &crate::provider_readiness::ProviderReadinessSnapshot,
1448 ) {
1449 for id in model_ids {
1450 if id == "auto" {
1451 continue;
1452 }
1453 let readiness =
1454 crate::provider_readiness::resolve_for_model(config, provider, &id, provider_health);
1455 let selectable = readiness.can_attempt();
1456 let readiness_label = readiness.label();
1457 let roster_entry = if provider == ApiProvider::OpenaiCodex {
1458 codex_roster.metadata_for(&id)
1459 } else {
1460 None
1461 };
1462 let codex_metadata = if codex_roster.freshness == CodexModelCacheFreshness::Fresh {
1463 roster_entry
1464 } else {
1465 None
1466 };
1467 let codex_freshness = roster_entry.map(|_| codex_roster.freshness);
1468 let metadata =
1469 effective_picker_metadata_with_codex(config, Some(provider), &id, codex_metadata);
1470 let mut hint = render_picker_model_hint(&id, Some(provider), &metadata, codex_freshness);
1471 hint = format!("{readiness_label} · {hint}");
1472 if provider != active_provider {
1473 hint = format!("switch route · {hint}");
1474 }
1475 let blocked_reason = (!selectable).then(|| readiness_label.to_string());
1476 push_model_row(
1477 rows,
1478 id.clone(),
1479 Some(provider),
1480 hint,
1481 metadata,
1482 selectable,
1483 blocked_reason,
1484 );
1485 }
1486 }
1487
1488 fn push_auto_model_row(rows: &mut Vec<ModelPickerRow>, app: &App, config: &Config, hint: &str) {
1489 let readiness = crate::provider_readiness::resolve_for_model(
1490 config,
1491 app.api_provider,
1492 "auto",
1493 &app.provider_health,
1494 );
1495 let metadata = effective_picker_metadata(config, None, "auto");
1496 let selectable = readiness.can_attempt();
1497 let blocked_reason = (!selectable).then(|| readiness.label().to_string());
1498 push_model_row(
1499 rows,
1500 "auto".to_string(),
1501 None,
1502 format!("{} · {hint}", readiness.label()),
1503 metadata,
1504 selectable,
1505 blocked_reason,
1506 );
1507 }
1508
1509 fn auto_picker_hint(app: &App, config: &Config) -> String {
1510 let inventory = crate::model_inventory::ModelInventory::from_config(config);
1511 // #4411: the classifier only sees other providers under the persisted
1512 // `[auto] cross_provider` opt-in, so the default hint says active provider
1513 // only and names the classifier route it will actually call.
1514 let hint_id = match (inventory.router_available, inventory.cross_provider_auto) {
1515 (true, true) => MessageId::ModelPickerAutoNetworkHint,
1516 (true, false) => MessageId::ModelPickerAutoNetworkActiveProviderHint,
1517 (false, _) => MessageId::ModelPickerAutoLocalHint,
1518 };
1519 let mut hint = app
1520 .tr(hint_id)
1521 .into_owned()
1522 .replace("{provider}", inventory.router_provider.display_name())
1523 .replace("{model}", &inventory.router_model);
1524 if let (Some(provider), Some(model)) = (
1525 app.last_effective_provider,
1526 app.last_effective_model.as_deref(),
1527 ) {
1528 let provider_label = if provider == ApiProvider::Custom {
1529 app.last_effective_provider_identity
1530 .as_deref()
1531 .unwrap_or_else(|| app.provider_identity_for_persistence())
1532 } else {
1533 provider.display_name()
1534 };
1535 let last = app
1536 .tr(MessageId::ModelPickerAutoLastRoute)
1537 .replace("{provider}", provider_label)
1538 .replace("{model}", model);
1539 hint.push_str(" · ");
1540 hint.push_str(&last);
1541 }
1542 hint
1543 }
1544
1545 fn push_configured_provider_model(
1546 models: &mut Vec<String>,
1547 config: &Config,
1548 provider: ApiProvider,
1549 ) {
1550 if let Some(model) = config
1551 .provider_config_for(provider)
1552 .and_then(|entry| entry.model.as_deref())
1553 .map(str::trim)
1554 .filter(|model| !model.is_empty())
1555 {
1556 push_model_id(
1557 models,
1558 picker_visible_model_id(
1559 provider,
1560 model,
1561 config.model_ids_pass_through_for_provider(provider),
1562 ),
1563 );
1564 }
1565 }
1566
1567 fn provider_catalog_model_ids(provider: ApiProvider) -> Vec<String> {
1568 let mut models = Vec::new();
1569 for id in all_catalog_models_for_provider(provider) {
1570 // The catalog describes the built-in provider route. A custom route's
1571 // endpoint-owned current/configured model is appended separately.
1572 push_model_id(&mut models, picker_visible_model_id(provider, &id, false));
1573 }
1574 models
1575 }
1576
1577 fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) -> Vec<String> {
1578 // `include_current_model` is for completion surfaces that do not have a
1579 // separate custom/current-model row.
1580 let mut models = Vec::new();
1581 push_model_id(&mut models, "auto");
1582 for id in provider_catalog_model_ids(app.api_provider) {
1583 push_model_id(&mut models, &id);
1584 }
1585
1586 if let Some(model) = app
1587 .provider_models
1588 .get(app.provider_identity_for_persistence())
1589 .map(|model| model.trim())
1590 .filter(|model| !model.is_empty())
1591 {
1592 push_model_id(
1593 &mut models,
1594 picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
1595 );
1596 }
1597
1598 if include_current_model && !app.auto_model {
1599 push_model_id(
1600 &mut models,
1601 picker_visible_model_id(
1602 app.api_provider,
1603 app.model.trim(),
1604 app.accepts_custom_model_ids(),
1605 ),
1606 );
1607 }
1608
1609 models
1610 }
1611
1612 fn push_model_id(models: &mut Vec<String>, model: &str) {
1613 let model = model.trim();
1614 if model.is_empty() {
1615 return;
1616 }
1617 if !models
1618 .iter()
1619 .any(|existing| existing.eq_ignore_ascii_case(model))
1620 {
1621 models.push(model.to_string());
1622 }
1623 }
1624
1625 /// Migrate retired aliases out of first-party DeepSeek model choices. Custom
1626 /// endpoints and aggregators own their namespaces, where `deepseek-reasoner`
1627 /// can remain a native wire id.
1628 fn picker_visible_model_id(
1629 provider: ApiProvider,
1630 model: &str,
1631 preserve_endpoint_model_ids: bool,
1632 ) -> &str {
1633 if !preserve_endpoint_model_ids
1634 && matches!(
1635 provider,
1636 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
1637 )
1638 && (model.eq_ignore_ascii_case("deepseek-chat")
1639 || model.eq_ignore_ascii_case("deepseek-reasoner"))
1640 {
1641 DEEPSEEK_ALIAS_REPLACEMENT
1642 } else {
1643 model
1644 }
1645 }
1646
1647 fn provider_query_splits(query: &str) -> Vec<(&str, &str)> {
1648 let trimmed = query.trim();
1649 let mut splits = Vec::new();
1650 if let Some((provider, model)) = trimmed.split_once(':') {
1651 splits.push((provider.trim(), model.trim()));
1652 }
1653 if let Some(idx) = trimmed.find(char::is_whitespace) {
1654 let (provider, model) = trimmed.split_at(idx);
1655 splits.push((provider.trim(), model.trim()));
1656 }
1657 splits
1658 }
1659
1660 fn push_model_row(
1661 rows: &mut Vec<ModelPickerRow>,
1662 id: String,
1663 provider: Option<ApiProvider>,
1664 hint: String,
1665 metadata: EffectivePickerMetadata,
1666 selectable: bool,
1667 blocked_reason: Option<String>,
1668 ) {
1669 if rows
1670 .iter()
1671 .any(|row| row.id == id && row.provider == provider)
1672 {
1673 return;
1674 }
1675 rows.push(ModelPickerRow {
1676 id,
1677 provider,
1678 provider_identity: None,
1679 hint,
1680 metadata,
1681 selectable,
1682 blocked_reason,
1683 enabled: false,
1684 });
1685 }
1686
1687 /// Compact Models.dev freshness chip for the picker chrome (#4139).
1688 ///
1689 /// Fresh/live rows stay unmarked; stale and failed caches get an explicit
1690 /// suffix so users know the live layer is still visible but not current.
1691 fn catalog_freshness_title_suffix() -> &'static str {
1692 match models_dev_live::status().freshness {
1693 ModelsDevFreshness::Stale => " · stale",
1694 ModelsDevFreshness::Failed => " · cache failed",
1695 ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
1696 }
1697 }
1698
1699 /// Cross-field search (#4141): match a query against the provider name
1700 /// (provider key + display name), the display model name, and the wire model
1701 /// id, mirroring `ProviderDashboardRow::matches_query` so the two pickers behave
1702 /// consistently. `row.id` is both the model's display name and the id it is
1703 /// sent to the provider as, so matching it covers the display model name and
1704 /// the wire model id. The compact hint is only searched for the active
1705 /// provider / `auto` rows, preserving the existing cross-provider behavior.
1706 fn model_row_matches_query(
1707 row: &ModelPickerRow,
1708 query: &str,
1709 initial_provider: ApiProvider,
1710 ) -> bool {
1711 let query = query.trim().to_ascii_lowercase();
1712 if query.is_empty() {
1713 return true;
1714 }
1715 let normalized_query = normalize_picker_search_text(&query);
1716 let matches = |candidate: &str| {
1717 let candidate = candidate.to_ascii_lowercase();
1718 candidate.contains(&query)
1719 || normalize_picker_search_text(&candidate).contains(&normalized_query)
1720 };
1721 let provider_matches = row.provider.is_some_and(|provider| {
1722 row.provider_identity.as_deref().is_some_and(matches)
1723 || matches(provider.as_str())
1724 || matches(provider.display_name())
1725 });
1726 provider_matches
1727 || matches(&row.id)
1728 || ((row.provider.is_none() || row.provider == Some(initial_provider))
1729 && matches(&row.hint))
1730 }
1731
1732 fn normalize_picker_search_text(text: &str) -> String {
1733 text.chars()
1734 .map(|ch| {
1735 if ch.is_ascii_alphanumeric() {
1736 ch.to_ascii_lowercase()
1737 } else {
1738 ' '
1739 }
1740 })
1741 .collect::<String>()
1742 .split_whitespace()
1743 .collect::<Vec<_>>()
1744 .join(" ")
1745 }
1746
1747 /// Route-identity labels for a set of rows, disambiguated where two providers
1748 /// answer to the same display name.
1749 ///
1750 /// `Deepseek` and `DeepseekAnthropic` are both spelled "DeepSeek", so a picker
1751 /// listing both showed two rows of literally identical text for two genuinely
1752 /// different endpoints. When a display name is not unique among the rows on
1753 /// offer, the provider's own id — the `[providers.<id>]` key the user would
1754 /// edit — supplies the discriminator, with the leading run it already shares
1755 /// with the display name removed so the suffix is the part that differs.
1756 fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<&'static str, String> {
1757 let mut by_display: BTreeMap<&'static str, Vec<ApiProvider>> = BTreeMap::new();
1758 for provider in rows.iter().filter_map(|row| row.provider) {
1759 let bucket = by_display.entry(provider.display_name()).or_default();
1760 if !bucket.contains(&provider) {
1761 bucket.push(provider);
1762 }
1763 }
1764 let mut labels = BTreeMap::new();
1765 for (display, providers) in by_display {
1766 let ambiguous = providers.len() > 1;
1767 for provider in providers {
1768 let label = match ambiguous.then(|| route_discriminator(display, provider.as_str())) {
1769 Some(Some(suffix)) => format!("{display} {suffix}"),
1770 // The canonical route — the one whose id is just the display
1771 // name — keeps the bare name; provider ids are unique, so at
1772 // most one member of a group can land here and the labels stay
1773 // distinct.
1774 Some(None) | None => display.to_string(),
1775 };
1776 labels.insert(provider.as_str(), label);
1777 }
1778 }
1779 labels
1780 }
1781
1782 /// The part of a provider id that is not already carried by its display name.
1783 fn route_discriminator(display: &str, provider_id: &str) -> Option<String> {
1784 let squash = |text: &str| -> String {
1785 text.chars()
1786 .filter(|c| c.is_alphanumeric())
1787 .collect::<String>()
1788 };
1789 let display_key = squash(display).to_ascii_lowercase();
1790 let id_key = squash(provider_id).to_ascii_lowercase();
1791 if display_key.is_empty() || !id_key.starts_with(&display_key) {
1792 return None;
1793 }
1794 // Walk the raw id until the display name's alphanumerics are consumed; what
1795 // remains is the endpoint-specific tail (`-anthropic`, `-CN`, …).
1796 // Count CHARACTERS, not bytes: `display_key.len()` is a byte length, and
1797 // for a non-ASCII display name it exceeds the alphanumeric char count, so
1798 // the loop would over-consume and the discriminator would be wrong or
1799 // empty (2026-08-04 review).
1800 let display_key_chars = display_key.chars().count();
1801 let mut consumed = 0usize;
1802 let mut tail = provider_id;
1803 for (offset, ch) in provider_id.char_indices() {
1804 if consumed == display_key_chars {
1805 tail = &provider_id[offset..];
1806 break;
1807 }
1808 if ch.is_alphanumeric() {
1809 consumed += 1;
1810 }
1811 tail = &provider_id[offset + ch.len_utf8()..];
1812 }
1813 let tail = tail.trim_matches(|c: char| !c.is_alphanumeric());
1814 (!tail.is_empty()).then(|| tail.to_string())
1815 }
1816
1817 /// The handful of facts that actually differ between neighbouring model rows,
1818 /// in the order they earn their space.
1819 ///
1820 /// Everything the old prose hint carried but that reads the same on nearly
1821 /// every row — `tools`, `no vision`, `price unknown`, `bundled` — is dropped
1822 /// here: a token repeated on forty rows cannot tell them apart, and it is what
1823 /// pushed the differentiating tokens off the end of the line. Facts the
1824 /// registry does not know are omitted rather than guessed.
1825 /// The catalog model family for a provider/model row, when the catalog
1826 /// states one. Used for Provider → family → model grouping; unknown families
1827 /// render no header (never a guessed label).
1828 fn catalog_family_for(provider: ApiProvider, model_id: &str) -> Option<String> {
1829 crate::provider_lake::catalog_offering_for_model(provider, model_id)
1830 .and_then(|offering| offering.family)
1831 }
1832
1833 fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
1834 let mut chips = Vec::new();
1835 if let Some(context_window) = row.metadata.context_window {
1836 chips.push(format_picker_context_window(u64::from(context_window)));
1837 }
1838 // The reasoning stance is the most decision-relevant fact for a coding
1839 // harness, so it sits before the limits/modality chips — the chip budget
1840 // sheds from the tail, and a squeezed row must never lose the stance.
1841 chips.push(
1842 if row.metadata.reasoning {
1843 "reasoning"
1844 } else {
1845 "no reasoning"
1846 }
1847 .to_string(),
1848 );
1849 if let Some(max_output) = row.metadata.max_output {
1850 chips.push(format!("{max_output} out"));
1851 }
1852 // Modality and tool facts are shown only when the catalog genuinely knows
1853 // them — an unknown is never rendered as a claim.
1854 match row.metadata.vision {
1855 SupportState::Supported => chips.push("vision".to_string()),
1856 SupportState::Unsupported => chips.push("text only".to_string()),
1857 SupportState::Unknown => {}
1858 }
1859 if let Some(tool_calls) = row.metadata.tool_calls {
1860 chips.push(if tool_calls {
1861 "tools".to_string()
1862 } else {
1863 "no tools".to_string()
1864 });
1865 }
1866 if let Some(reason) = row.blocked_reason.as_deref() {
1867 chips.push(reason.to_string());
1868 }
1869 chips
1870 }
1871
1872 /// Join metadata chips, dropping the lowest-priority ones until the result
1873 /// fits. Truncating mid-chip would render a half-word fact, so whole chips are
1874 /// shed instead.
1875 fn fit_meta_chips(chips: &[String], width: usize) -> String {
1876 for take in (1..=chips.len()).rev() {
1877 let joined = chips[..take].join(" · ");
1878 if unicode_width::UnicodeWidthStr::width(joined.as_str()) <= width {
1879 return joined;
1880 }
1881 }
1882 // A single chip that still does not fit is prose (an `auto` explanation or
1883 // an effort description) rather than a fact token, so it is truncated
1884 // instead of dropped — but only when the column can hold something worth
1885 // reading.
1886 match chips.first() {
1887 Some(first) if width >= MIN_META_WIDTH => fit_text(first, width),
1888 _ => String::new(),
1889 }
1890 }
1891
1892 /// Whether a model row shows in the active catalog view (#3830 / #4115).
1893 fn model_row_visible_in_view(row: &ModelPickerRow, view: ModelListView) -> bool {
1894 match view {
1895 ModelListView::Configured => model_row_visible_by_default(row),
1896 ModelListView::Catalog => true,
1897 ModelListView::Recent
1898 | ModelListView::Coding
1899 | ModelListView::Cheap
1900 | ModelListView::LongContext => {
1901 // Discoverability views browse the full lake but hide the synthetic
1902 // `auto` row — it is not a catalog offering.
1903 row.provider.is_some() || row.id != "auto"
1904 }
1905 }
1906 }
1907
1908 /// Whether a model row shows up without the user typing a search query
1909 /// (#3830): `auto`, the active provider's own rows, and any other
1910 /// provider's rows once that provider is "configured" — same definition the
1911 /// `/provider` manager's default view uses.
1912 fn model_row_visible_by_default(row: &ModelPickerRow) -> bool {
1913 row.provider.is_none() || row.enabled
1914 }
1915
1916 fn sort_model_rows_for_view(
1917 rows: &mut [&ModelPickerRow],
1918 view: ModelListView,
1919 pins: &[PinnedModel],
1920 ) {
1921 let pin_rank = |row: &ModelPickerRow| {
1922 row_provider_identity(row)
1923 .and_then(|provider| {
1924 pins.iter().position(|pin| {
1925 provider.eq_ignore_ascii_case(&pin.provider)
1926 && row.id.eq_ignore_ascii_case(&pin.model)
1927 })
1928 })
1929 .unwrap_or(usize::MAX)
1930 };
1931 match view {
1932 ModelListView::Configured | ModelListView::Catalog => rows.sort_by_key(|row| pin_rank(row)),
1933 ModelListView::Recent => rows.sort_by(|left, right| {
1934 offering_fetched_at(right)
1935 .cmp(&offering_fetched_at(left))
1936 .then_with(|| left.id.cmp(&right.id))
1937 }),
1938 ModelListView::Coding => rows.sort_by(|left, right| {
1939 coding_score(right)
1940 .cmp(&coding_score(left))
1941 .then_with(|| left.id.cmp(&right.id))
1942 }),
1943 ModelListView::Cheap => rows.sort_by(|left, right| {
1944 match (
1945 input_price_per_million(left),
1946 input_price_per_million(right),
1947 ) {
1948 (Some(l), Some(r)) => l
1949 .partial_cmp(&r)
1950 .unwrap_or(std::cmp::Ordering::Equal)
1951 .then_with(|| left.id.cmp(&right.id)),
1952 (Some(_), None) => std::cmp::Ordering::Less,
1953 (None, Some(_)) => std::cmp::Ordering::Greater,
1954 (None, None) => left.id.cmp(&right.id),
1955 }
1956 }),
1957 ModelListView::LongContext => rows.sort_by(|left, right| {
1958 context_tokens(right)
1959 .cmp(&context_tokens(left))
1960 .then_with(|| left.id.cmp(&right.id))
1961 }),
1962 }
1963 }
1964
1965 fn row_provider_identity(row: &ModelPickerRow) -> Option<&str> {
1966 row.provider_identity.as_deref().or_else(|| {
1967 row.provider
1968 .filter(|provider| *provider != ApiProvider::Custom)
1969 .map(ApiProvider::as_str)
1970 })
1971 }
1972
1973 fn offering_for_row(row: &ModelPickerRow) -> Option<codewhale_config::catalog::CatalogOffering> {
1974 let provider = row.provider?;
1975 catalog_offering_for_model(provider, &row.id)
1976 }
1977
1978 fn offering_fetched_at(row: &ModelPickerRow) -> u64 {
1979 match offering_for_row(row).map(|o| o.source) {
1980 Some(CatalogSource::Live { fetched_at, .. }) => fetched_at,
1981 _ => 0,
1982 }
1983 }
1984
1985 fn context_tokens(row: &ModelPickerRow) -> u64 {
1986 row.metadata.context_window.map(u64::from).unwrap_or(0)
1987 }
1988
1989 fn input_price_per_million(row: &ModelPickerRow) -> Option<f64> {
1990 if matches!(row.metadata.pricing, PickerPricing::Unavailable) {
1991 return None;
1992 }
1993 offering_for_row(row)
1994 .and_then(|offering| OfferingPricing::from_catalog_offering(&offering))
1995 .and_then(|pricing| pricing.input_per_million)
1996 }
1997
1998 fn coding_score(row: &ModelPickerRow) -> u32 {
1999 let mut score = 0_u32;
2000 if let Some(offering) = offering_for_row(row) {
2001 let text_ok = offering.modalities.as_ref().is_none_or(|modalities| {
2002 modalities.output.is_empty()
2003 || modalities
2004 .output
2005 .iter()
2006 .any(|m| m.eq_ignore_ascii_case("text"))
2007 });
2008 if text_ok {
2009 score += 40;
2010 }
2011 }
2012 if row.metadata.tool_calls == Some(true) {
2013 score += 40;
2014 }
2015 if row.metadata.reasoning {
2016 score += 10;
2017 }
2018 if row.metadata.context_window.unwrap_or(0) >= 100_000 {
2019 score += 10;
2020 }
2021 score
2022 }
2023
2024 #[cfg(test)]
2025 fn picker_model_hint(id: &str, provider: Option<ApiProvider>) -> String {
2026 let config = Config::default();
2027 let metadata = effective_picker_metadata(&config, provider, id);
2028 let codex_freshness = (provider == Some(ApiProvider::OpenaiCodex))
2029 .then(|| codex_model_cache::model_roster().freshness);
2030 render_picker_model_hint(id, provider, &metadata, codex_freshness)
2031 }
2032
2033 fn effective_picker_metadata(
2034 config: &Config,
2035 provider: Option<ApiProvider>,
2036 id: &str,
2037 ) -> EffectivePickerMetadata {
2038 effective_picker_metadata_with_codex(config, provider, id, None)
2039 }
2040
2041 fn effective_picker_metadata_with_codex(
2042 config: &Config,
2043 provider: Option<ApiProvider>,
2044 id: &str,
2045 codex_metadata: Option<&CodexModelMetadata>,
2046 ) -> EffectivePickerMetadata {
2047 let offering = provider.and_then(|provider| catalog_offering_for_model(provider, id));
2048 let card = offering.as_ref().map(ModelReferenceCard::from_offering);
2049 let registry = model_registry::lookup(id);
2050
2051 let Some(provider) = provider else {
2052 return EffectivePickerMetadata {
2053 context_window: registry.as_ref().and_then(|meta| meta.context_window),
2054 max_output: registry.as_ref().and_then(|meta| meta.max_output),
2055 tool_calls: None,
2056 reasoning: registry
2057 .as_ref()
2058 .is_some_and(|meta| meta.supports_reasoning),
2059 vision: SupportState::Unknown,
2060 pricing: if crate::pricing::has_pricing_for_model(id) {
2061 PickerPricing::Known("priced".to_string())
2062 } else {
2063 PickerPricing::Unknown
2064 },
2065 source: None,
2066 };
2067 };
2068
2069 let context_override = config.context_window_for_provider_config(provider);
2070 let overrides = CapabilityOverride {
2071 context_window: context_override,
2072 ..CapabilityOverride::default()
2073 };
2074 let profile = offering.as_ref().map_or_else(
2075 || resolved_capability_profile_with_overrides(provider, id, overrides.clone()),
2076 |offering| {
2077 let route_offering = offering.to_offering();
2078 resolved_capability_profile_for_route_with_overrides(
2079 provider,
2080 id,
2081 route_offering.capabilities,
2082 route_offering.limits,
2083 overrides.clone(),
2084 )
2085 },
2086 );
2087 let card_context = card
2088 .as_ref()
2089 .and_then(|card| card.context_window)
2090 .map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
2091 let preserves_unknown_limits = offering.is_some()
2092 || (provider == ApiProvider::Together
2093 && id.eq_ignore_ascii_case(crate::config::TOGETHER_INKLING_MODEL));
2094 let context_window = if context_override.is_some() {
2095 profile.context_window
2096 } else if provider == ApiProvider::OpenaiCodex {
2097 codex_metadata.and_then(|metadata| metadata.context_window)
2098 } else if preserves_unknown_limits {
2099 card_context
2100 } else {
2101 profile.context_window
2102 };
2103 let card_output = card
2104 .as_ref()
2105 .and_then(|card| card.max_output)
2106 .map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
2107 // The Codex cache does not publish a route-owned output ceiling. The
2108 // profile's current value is inherited from the same-id OpenAI API model,
2109 // so omitting it is more truthful than claiming that API limit for OAuth.
2110 let max_output = if provider == ApiProvider::OpenaiCodex {
2111 None
2112 } else if preserves_unknown_limits {
2113 card_output
2114 } else {
2115 profile.max_output
2116 };
2117 let profile_tool_calls = match profile.native_tool_calls {
2118 SupportState::Supported => Some(true),
2119 SupportState::Unsupported => Some(false),
2120 SupportState::Unknown => None,
2121 };
2122 let tool_calls = if provider == ApiProvider::OpenaiCodex {
2123 codex_metadata.and(profile_tool_calls)
2124 } else {
2125 offering
2126 .as_ref()
2127 .and_then(|offering| offering.tool_call)
2128 .or(profile_tool_calls)
2129 };
2130 let reasoning = if provider == ApiProvider::OpenaiCodex {
2131 codex_metadata
2132 .map(|metadata| {
2133 metadata
2134 .reasoning
2135 .unwrap_or_else(|| profile.supports_reasoning())
2136 })
2137 .unwrap_or(false)
2138 } else {
2139 offering
2140 .as_ref()
2141 .and_then(|offering| offering.reasoning)
2142 .unwrap_or_else(|| profile.supports_reasoning())
2143 };
2144 let vision = profile.image_input;
2145 let card_price = card.as_ref().and_then(|card| {
2146 let label = card.price_label();
2147 (label != "unknown").then_some(label)
2148 });
2149 let pricing = if provider == ApiProvider::OpenaiCodex {
2150 PickerPricing::Unavailable
2151 } else if let Some(label) = card_price {
2152 PickerPricing::Known(label)
2153 } else if crate::pricing::has_pricing_for_provider(provider, id) {
2154 PickerPricing::Known("priced".to_string())
2155 } else {
2156 PickerPricing::Unknown
2157 };
2158
2159 EffectivePickerMetadata {
2160 context_window,
2161 max_output,
2162 tool_calls,
2163 reasoning,
2164 vision,
2165 pricing,
2166 source: card.map(|card| card.source),
2167 }
2168 }
2169
2170 fn render_picker_model_hint(
2171 id: &str,
2172 provider: Option<ApiProvider>,
2173 metadata: &EffectivePickerMetadata,
2174 codex_freshness: Option<CodexModelCacheFreshness>,
2175 ) -> String {
2176 debug_assert_ne!(id, "auto", "Auto rows use the context-aware picker hint");
2177
2178 let mut parts = Vec::new();
2179
2180 // `k3` and `kimi-k3` are the same underlying model on two different
2181 // products, so bare ids read as a confusing duplicate. Name the route:
2182 // bare `k3` is the Kimi Code membership route (validated pairing with
2183 // the coding endpoint, #4687), `kimi-k3` is the direct open platform.
2184 if provider == Some(ApiProvider::Moonshot) {
2185 match id.trim().to_ascii_lowercase().as_str() {
2186 "k3" => parts.push("Kimi Code plan route".to_string()),
2187 "kimi-k3" | "moonshotai/kimi-k3" => parts.push("Moonshot direct route".to_string()),
2188 _ => {}
2189 }
2190 }
2191
2192 if let Some(context_window) = metadata.context_window {
2193 // The ChatGPT/Codex OAuth roster reports account-scoped windows (e.g.
2194 // 272K for gpt-5.x) that differ from the API route's limits by
2195 // deliberate policy. Label the value as route-scoped so it reads as a
2196 // route fact, not a wrong generic model limit (TUI-DOG-016).
2197 if provider == Some(ApiProvider::OpenaiCodex) {
2198 parts.push(format!(
2199 "{} ctx · ChatGPT route",
2200 format_picker_context_window(u64::from(context_window))
2201 ));
2202 } else if provider == Some(ApiProvider::Moonshot)
2203 && id.trim().eq_ignore_ascii_case("k3")
2204 && context_window == crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS
2205 {
2206 // The membership route's real window is plan-tier dependent
2207 // (256K on lower tiers, up to 1M on higher ones); this default
2208 // is the safe floor, raisable via the provider's
2209 // `context_window` setting when the plan includes 1M.
2210 parts.push(format!(
2211 "{} ctx (plan floor; raise via context_window)",
2212 format_picker_context_window(u64::from(context_window))
2213 ));
2214 } else {
2215 parts.push(format!(
2216 "{} ctx",
2217 format_picker_context_window(u64::from(context_window))
2218 ));
2219 }
2220 }
2221
2222 if let Some(max_output) = metadata.max_output {
2223 parts.push(format!(
2224 "{} out",
2225 format_picker_context_window(u64::from(max_output))
2226 ));
2227 }
2228
2229 match metadata.tool_calls {
2230 Some(true) => parts.push("tools".to_string()),
2231 Some(false) => parts.push("no tools".to_string()),
2232 None => {}
2233 }
2234
2235 if metadata.reasoning {
2236 parts.push("reasoning".to_string());
2237 }
2238
2239 match metadata.vision {
2240 SupportState::Supported => parts.push("vision".to_string()),
2241 SupportState::Unsupported => parts.push("no vision".to_string()),
2242 SupportState::Unknown => {}
2243 }
2244
2245 match &metadata.pricing {
2246 PickerPricing::Unavailable => {}
2247 PickerPricing::Known(label) => parts.push(label.clone()),
2248 PickerPricing::Unknown => parts.push("price unknown".to_string()),
2249 }
2250 match metadata.source.as_ref() {
2251 Some(CatalogSource::Live { .. }) => parts.push("live".to_string()),
2252 Some(CatalogSource::Bundled) => parts.push("bundled".to_string()),
2253 Some(CatalogSource::UserOverride) => parts.push("override".to_string()),
2254 None => {}
2255 }
2256 if provider == Some(ApiProvider::OpenaiCodex) {
2257 parts.push(match codex_freshness {
2258 Some(freshness) => freshness.picker_label().to_string(),
2259 None => "custom · OAuth roster unconfirmed".to_string(),
2260 });
2261 }
2262
2263 if parts.is_empty() {
2264 "provider model".to_string()
2265 } else {
2266 parts.join(" · ")
2267 }
2268 }
2269
2270 pub(crate) fn format_picker_context_window(tokens: u64) -> String {
2271 if tokens >= 1_000_000 {
2272 if tokens.is_multiple_of(1_000_000) {
2273 format!("{}M", tokens / 1_000_000)
2274 } else {
2275 format!("{:.2}M", tokens as f64 / 1_000_000.0)
2276 .trim_end_matches('0')
2277 .trim_end_matches('.')
2278 .to_string()
2279 }
2280 } else if tokens >= 1_000 {
2281 format!("{}K", tokens / 1_000)
2282 } else {
2283 tokens.to_string()
2284 }
2285 }
2286
2287 impl ModelPickerView {
2288 /// Rebuild model rows from a fresh app/config snapshot (readiness + catalog).
2289 pub fn re_resolve_from_app(&mut self, app: &App, config: &Config) {
2290 let selected = self
2291 .visible_model_rows()
2292 .get(self.selected_model_idx)
2293 .map(|row| {
2294 (
2295 row_provider_identity(row).map(str::to_string),
2296 row.id.clone(),
2297 )
2298 });
2299 self.provider_health = app.provider_health.clone();
2300 self.route_config = config.clone();
2301 self.pinned_models = app.pinned_models.clone();
2302 self.model_rows = picker_model_rows_for_app(app, config);
2303 self.configured_providers = configured_providers(config, app.api_provider)
2304 .into_iter()
2305 .filter(|provider| *provider != app.api_provider)
2306 .collect();
2307 // Re-anchor to the same exact provider/model after pin sorting changes;
2308 // preserving only the numeric index can select a different model.
2309 if let Some((provider, model)) = selected
2310 && let Some(position) = self.visible_model_rows().iter().position(|row| {
2311 row.id.eq_ignore_ascii_case(&model)
2312 && row_provider_identity(row).map(str::to_owned) == provider
2313 })
2314 {
2315 self.selected_model_idx = position;
2316 return;
2317 }
2318 // Keep selection stable when the row still exists.
2319 let rows = self.visible_model_rows();
2320 if self.selected_model_idx >= rows.len() + usize::from(self.show_custom_model_row) {
2321 self.selected_model_idx = rows.len().saturating_sub(1);
2322 }
2323 }
2324 }
2325
2326 impl ModelPickerView {
2327 fn emit_pin_move(&self, delta: isize) -> ViewAction {
2328 let rows = self.visible_model_rows();
2329 let Some(row) = rows.get(self.selected_model_idx) else {
2330 return ViewAction::None;
2331 };
2332 let Some(provider) = row.provider else {
2333 return ViewAction::None;
2334 };
2335 ViewAction::Emit(ViewEvent::ModelPickerMovePin {
2336 provider,
2337 provider_id: row.provider_identity.clone(),
2338 model: row.id.clone(),
2339 delta,
2340 })
2341 }
2342 }
2343
2344 impl ModalView for ModelPickerView {
2345 fn kind(&self) -> ModalKind {
2346 ModalKind::ModelPicker
2347 }
2348
2349 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
2350 self
2351 }
2352
2353 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
2354 match key.code {
2355 // Esc carries the browsing context out so the next open can
2356 // restore it (#4109 picker memory).
2357 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
2358 catalog_view: self.view.browses_all_providers(),
2359 view: self.view.memory_name().to_string(),
2360 selected_row_id: {
2361 let rows = self.visible_model_rows();
2362 rows.get(self.selected_model_idx).map(|row| row.id.clone())
2363 },
2364 }),
2365 KeyCode::Enter if self.model_row_count() == 0 => ViewAction::None,
2366 KeyCode::Enter if !self.selected_model_is_selectable() => {
2367 // Never silently ignore Enter on locked models — surface the
2368 // readiness reason and offer provider setup.
2369 self.explain_unselectable_selection()
2370 }
2371 KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
2372 // Shift+D makes the visible provider/model pair the startup
2373 // default. Plain Enter deliberately stays session-local, so a
2374 // one-off route comparison cannot silently change the next launch.
2375 KeyCode::Char(ch)
2376 if key.modifiers.contains(KeyModifiers::SHIFT)
2377 && self.query.is_empty()
2378 && ch.eq_ignore_ascii_case(&'d')
2379 && self.selected_model_is_selectable() =>
2380 {
2381 ViewAction::EmitAndClose(self.build_event_with_startup_default(true))
2382 }
2383 KeyCode::Char(ch)
2384 if key.modifiers.contains(KeyModifiers::SHIFT) && ch.eq_ignore_ascii_case(&'d') =>
2385 {
2386 self.explain_unselectable_selection()
2387 }
2388 KeyCode::Char('p') if key.modifiers.is_empty() && self.query.is_empty() => {
2389 let rows = self.visible_model_rows();
2390 let Some(row) = rows.get(self.selected_model_idx) else {
2391 return ViewAction::None;
2392 };
2393 let Some(provider) = row.provider else {
2394 return ViewAction::None;
2395 };
2396 ViewAction::Emit(ViewEvent::ModelPickerTogglePin {
2397 provider,
2398 provider_id: row.provider_identity.clone(),
2399 model: row.id.clone(),
2400 })
2401 }
2402 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
2403 self.emit_pin_move(-1)
2404 }
2405 KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
2406 self.emit_pin_move(1)
2407 }
2408 // Cycle catalog views (#4115). Handled before the query-typing arm
2409 // so `a`/`A` always advances the view instead of filtering.
2410 KeyCode::Char(c)
2411 if key.modifiers.is_empty()
2412 && self.query.is_empty()
2413 && c.eq_ignore_ascii_case(&'a') =>
2414 {
2415 self.toggle_view();
2416 ViewAction::None
2417 }
2418 KeyCode::Char(ch)
2419 if self.focus == Pane::Model
2420 && !key
2421 .modifiers
2422 .contains(crossterm::event::KeyModifiers::CONTROL) =>
2423 {
2424 let mut query = self.query.clone();
2425 query.push(ch);
2426 self.update_query(query);
2427 ViewAction::None
2428 }
2429 KeyCode::Backspace if self.focus == Pane::Model && !self.query.is_empty() => {
2430 let mut query = self.query.clone();
2431 query.pop();
2432 self.update_query(query);
2433 ViewAction::None
2434 }
2435 KeyCode::Up => {
2436 self.move_up();
2437 ViewAction::None
2438 }
2439 KeyCode::Down => {
2440 self.move_down();
2441 ViewAction::None
2442 }
2443 KeyCode::PageUp => {
2444 for _ in 0..5 {
2445 self.move_up();
2446 }
2447 ViewAction::None
2448 }
2449 KeyCode::PageDown => {
2450 for _ in 0..5 {
2451 self.move_down();
2452 }
2453 ViewAction::None
2454 }
2455 KeyCode::Home => {
2456 match self.focus {
2457 Pane::Model => {
2458 self.selected_model_idx = 0;
2459 self.select_effort_for_current_model();
2460 }
2461 Pane::Effort => {
2462 self.selected_effort_idx = 0;
2463 self.selected_effort_request = self.resolved_effort();
2464 }
2465 }
2466 ViewAction::None
2467 }
2468 KeyCode::End => {
2469 match self.focus {
2470 Pane::Model => {
2471 self.selected_model_idx = self.model_row_count().saturating_sub(1);
2472 self.select_effort_for_current_model();
2473 }
2474 Pane::Effort => {
2475 self.selected_effort_idx = self.current_efforts().len().saturating_sub(1);
2476 self.selected_effort_request = self.resolved_effort();
2477 }
2478 }
2479 ViewAction::None
2480 }
2481 KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
2482 self.toggle_focus();
2483 ViewAction::None
2484 }
2485 // Explicit readiness + catalog refresh (safe, non-destructive).
2486 KeyCode::Char('r') | KeyCode::Char('R')
2487 if key
2488 .modifiers
2489 .contains(crossterm::event::KeyModifiers::CONTROL)
2490 || (key.modifiers.is_empty() && self.query.is_empty()) =>
2491 {
2492 ViewAction::Emit(ViewEvent::ModelPickerRefresh)
2493 }
2494 _ => ViewAction::None,
2495 }
2496 }
2497
2498 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
2499 match mouse.kind {
2500 MouseEventKind::ScrollUp => {
2501 self.last_mouse_selected = None;
2502 self.move_up();
2503 ViewAction::None
2504 }
2505 MouseEventKind::ScrollDown => {
2506 self.last_mouse_selected = None;
2507 self.move_down();
2508 ViewAction::None
2509 }
2510 MouseEventKind::Down(MouseButton::Left) => {
2511 let clicked = self
2512 .row_hitboxes
2513 .borrow()
2514 .iter()
2515 .find_map(|(rect, pane, idx)| {
2516 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
2517 .then_some((*pane, *idx))
2518 });
2519 let Some((pane, idx)) = clicked else {
2520 return ViewAction::None;
2521 };
2522 let apply = self.last_mouse_selected == Some((pane, idx))
2523 && self.focus == pane
2524 && match pane {
2525 Pane::Model => self.selected_model_idx == idx,
2526 Pane::Effort => self.selected_effort_idx == idx,
2527 };
2528 self.focus = pane;
2529 match pane {
2530 Pane::Model => {
2531 self.selected_model_idx = idx.min(self.model_row_count().saturating_sub(1));
2532 self.select_effort_for_current_model();
2533 }
2534 Pane::Effort => {
2535 self.selected_effort_idx =
2536 idx.min(self.current_efforts().len().saturating_sub(1));
2537 self.selected_effort_request = self.resolved_effort();
2538 }
2539 }
2540 self.last_mouse_selected = Some((pane, idx));
2541 if apply && self.selected_model_is_selectable() {
2542 ViewAction::EmitAndClose(self.build_event())
2543 } else if apply {
2544 self.explain_unselectable_selection()
2545 } else {
2546 ViewAction::None
2547 }
2548 }
2549 _ => ViewAction::None,
2550 }
2551 }
2552
2553 fn render(&self, area: Rect, buf: &mut Buffer) {
2554 self.render_route(area, buf);
2555 }
2556 }
2557
2558 impl ModelPickerView {
2559 fn render_route(&self, area: Rect, buf: &mut Buffer) {
2560 self.row_hitboxes.borrow_mut().clear();
2561 let inner = render_underwater_surface(
2562 area,
2563 buf,
2564 tr(self.locale, MessageId::RouteSurfaceTitle)
2565 .replace("{view}", self.view.title_label()),
2566 );
2567
2568 // Say what the action does in model language. Provider changes are an
2569 // implementation detail of applying a cross-provider model row.
2570 let view_action: std::borrow::Cow<'static, str> = match self.view {
2571 ModelListView::Configured => tr(self.locale, MessageId::RouteBrowseCatalog),
2572 other => other.next().title_label().into(),
2573 };
2574 let content = render_modal_footer(
2575 inner,
2576 buf,
2577 &[
2578 ActionHint::new("↑↓", tr(self.locale, MessageId::PickerActionMove)),
2579 ActionHint::new("Tab", tr(self.locale, MessageId::PickerActionSwitch)),
2580 ActionHint::new(
2581 tr(self.locale, MessageId::RouteActionType),
2582 tr(self.locale, MessageId::RouteActionSearchAnyModel),
2583 ),
2584 ActionHint::new("Enter", tr(self.locale, MessageId::PickerActionApply)),
2585 ActionHint::new(
2586 "⇧D",
2587 tr(self.locale, MessageId::PickerActionSetStartupDefault),
2588 ),
2589 ActionHint::new("A", view_action),
2590 ActionHint::new("Esc", tr(self.locale, MessageId::PickerActionCancel)),
2591 ],
2592 );
2593
2594 let shell = ratatui::layout::Layout::default()
2595 .direction(ratatui::layout::Direction::Vertical)
2596 .constraints([
2597 ratatui::layout::Constraint::Length(3),
2598 ratatui::layout::Constraint::Min(1),
2599 ])
2600 .split(content);
2601 Paragraph::new(vec![
2602 Line::from(vec![
2603 Span::styled(
2604 format!("─ {} ", tr(self.locale, MessageId::RoutePanelHeader)),
2605 Style::default().fg(palette::WHALE_ACTION).bold(),
2606 ),
2607 Span::styled(
2608 "──────────────────────── ",
2609 Style::default().fg(palette::BORDER_COLOR),
2610 ),
2611 Span::styled(
2612 format!(
2613 "{}{}",
2614 self.view.title_label(),
2615 catalog_freshness_title_suffix()
2616 ),
2617 Style::default().fg(palette::TEXT_MUTED),
2618 ),
2619 Span::styled(
2620 " ─────────────────",
2621 Style::default().fg(palette::BORDER_COLOR),
2622 ),
2623 ]),
2624 Line::from(""),
2625 Line::from(vec![
2626 Span::styled(
2627 format!(" {} ", tr(self.locale, MessageId::RouteProviderLabel)),
2628 Style::default().fg(palette::WHALE_INFO),
2629 ),
2630 Span::styled(
2631 self.resolved_provider()
2632 .unwrap_or(self.initial_provider)
2633 .display_name(),
2634 Style::default().fg(palette::TEXT_PRIMARY),
2635 ),
2636 Span::styled(
2637 format!(" · {}", tr(self.locale, MessageId::RouteModelFirstAtomic)),
2638 Style::default().fg(palette::TEXT_MUTED),
2639 ),
2640 ]),
2641 ])
2642 .render(shell[0], buf);
2643
2644 let layout = widen_model_pane(ListDetailLayout::split(shell[1], 24));
2645
2646 let visible = self.visible_model_rows();
2647 let route_labels = route_labels_for_rows(&visible);
2648 let mut model_rows: Vec<PaneRow> = visible
2649 .iter()
2650 .map(|row| {
2651 let active = row.id == self.initial_model
2652 && (row.provider.is_none() || row.provider == Some(self.initial_provider));
2653 match row.provider {
2654 // `auto` is not a catalog offering; it keeps its explanatory
2655 // prose, which now has the whole row to be truncated into
2656 // instead of being dropped for not fitting.
2657 None => PaneRow {
2658 primary: row.id.clone(),
2659 route: String::new(),
2660 meta: vec![row.hint.clone()],
2661 family: None,
2662 active,
2663 },
2664 Some(provider) => PaneRow {
2665 primary: row.id.clone(),
2666 route: route_labels
2667 .get(provider.as_str())
2668 .cloned()
2669 .unwrap_or_else(|| provider.display_name().to_string()),
2670 meta: model_row_meta_chips(row),
2671 family: catalog_family_for(provider, &row.id),
2672 active,
2673 },
2674 }
2675 })
2676 .collect();
2677 if let Some((model, provider)) = self.custom_model_row() {
2678 model_rows.push(PaneRow {
2679 primary: model,
2680 family: None,
2681 route: provider.display_name().to_string(),
2682 meta: vec![if self.query.trim().is_empty() {
2683 "current (custom)".to_string()
2684 } else {
2685 "custom route".to_string()
2686 }],
2687 active: false,
2688 });
2689 }
2690 let model_title = if self.query.trim().is_empty() {
2691 format!("Model · {}", self.view.title_label())
2692 } else {
2693 format!("Model: {}", self.query.trim())
2694 };
2695 self.render_pane(
2696 layout.list,
2697 buf,
2698 &model_title,
2699 model_rows,
2700 PaneRenderState {
2701 pane: Pane::Model,
2702 selected: self.selected_model_idx,
2703 focused: self.focus == Pane::Model,
2704 },
2705 );
2706
2707 let effort_provider = self.resolved_provider().unwrap_or(self.initial_provider);
2708 let current_efforts = self.current_efforts();
2709 let selected_effort_idx = self
2710 .selected_effort_idx
2711 .min(current_efforts.len().saturating_sub(1));
2712 let effort_rows: Vec<PaneRow> = current_efforts
2713 .iter()
2714 .map(|effort| {
2715 let label = effort
2716 .display_label_for_provider(effort_provider)
2717 .to_string();
2718 let hint = match effort {
2719 ReasoningEffort::Auto => "choose per turn".to_string(),
2720 ReasoningEffort::Off => "no extra reasoning".to_string(),
2721 ReasoningEffort::Minimal => "minimal reasoning".to_string(),
2722 ReasoningEffort::Low => "lighter reasoning".to_string(),
2723 ReasoningEffort::Medium => "balanced reasoning".to_string(),
2724 ReasoningEffort::High => "deeper reasoning".to_string(),
2725 ReasoningEffort::XHigh => "extra-high reasoning".to_string(),
2726 ReasoningEffort::Ultra => "ultra reasoning".to_string(),
2727 ReasoningEffort::Max => {
2728 if effort_provider == ApiProvider::OpenaiCodex {
2729 "extra-high reasoning".to_string()
2730 } else {
2731 "maximum reasoning".to_string()
2732 }
2733 }
2734 };
2735 PaneRow::effort(label, hint)
2736 })
2737 .collect();
2738 self.render_pane(
2739 layout.detail,
2740 buf,
2741 "Thinking",
2742 effort_rows,
2743 PaneRenderState {
2744 pane: Pane::Effort,
2745 selected: selected_effort_idx,
2746 focused: self.focus == Pane::Effort,
2747 },
2748 );
2749 }
2750 }
2751
2752 pub(crate) fn picker_efforts_for_route(
2753 provider: ApiProvider,
2754 base_url: &str,
2755 wire_model: &str,
2756 model_is_auto: bool,
2757 ) -> Vec<ReasoningEffort> {
2758 if model_is_auto {
2759 return AUTO_MODEL_PICKER_EFFORTS.to_vec();
2760 }
2761 // Exact-route overrides still win over catalog metadata: Kimi Code K3 and
2762 // OpenAI Codex have wire dialects the generic Models.dev shape does not
2763 // fully describe.
2764 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
2765 return KIMI_CODE_K3_PICKER_EFFORTS.to_vec();
2766 }
2767 if provider == ApiProvider::OpenaiCodex {
2768 return CODEX_PICKER_EFFORTS.to_vec();
2769 }
2770 if let Some(catalog_efforts) = catalog_picker_efforts(provider, wire_model) {
2771 return catalog_efforts;
2772 }
2773 if matches!(
2774 provider,
2775 crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
2776 ) {
2777 return DEEPSEEK_PICKER_EFFORTS.to_vec();
2778 }
2779 DEFAULT_PICKER_EFFORTS.to_vec()
2780 }
2781
2782 /// Build thinking-tier rows from Models.dev `reasoning_options` when present.
2783 ///
2784 /// Expected shape (already parsed onto the catalog offering):
2785 /// `[{ "type": "effort", "values": ["high", "max"] }]`.
2786 /// Non-effort option types (e.g. MiniMax `thinking`) are mapped when their
2787 /// values collapse cleanly onto our tier vocabulary; unknown values are
2788 /// skipped. Returns `None` when the catalog has no usable effort list so the
2789 /// caller can keep the provider default rather than inventing tiers.
2790 fn catalog_picker_efforts(provider: ApiProvider, wire_model: &str) -> Option<Vec<ReasoningEffort>> {
2791 let offering = catalog_offering_for_model(provider, wire_model)?;
2792 let mut efforts = Vec::new();
2793 let mut saw_effort_list = false;
2794 for option in &offering.reasoning_options {
2795 let option_type = option
2796 .get("type")
2797 .and_then(|value| value.as_str())
2798 .unwrap_or("")
2799 .to_ascii_lowercase();
2800 // Prefer explicit effort lists; also accept thinking-mode lists whose
2801 // values map onto our tiers (adaptive→auto, disabled→off, always_on→max).
2802 if option_type != "effort" && option_type != "thinking" {
2803 continue;
2804 }
2805 let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
2806 continue;
2807 };
2808 saw_effort_list = true;
2809 for value in values {
2810 let Some(raw) = value.as_str() else {
2811 continue;
2812 };
2813 if let Some(effort) = catalog_effort_value(raw)
2814 && !efforts.contains(&effort)
2815 {
2816 efforts.push(effort);
2817 }
2818 }
2819 }
2820 if !saw_effort_list || efforts.is_empty() {
2821 return None;
2822 }
2823 // Always offer Auto when the catalog published discrete tiers so the
2824 // operator can still leave the choice to the route default. Do not invent
2825 // Off unless the catalog said so — some models are always-on.
2826 if !efforts.contains(&ReasoningEffort::Auto) {
2827 efforts.insert(0, ReasoningEffort::Auto);
2828 }
2829 Some(efforts)
2830 }
2831
2832 fn catalog_effort_value(raw: &str) -> Option<ReasoningEffort> {
2833 match raw.trim().to_ascii_lowercase().as_str() {
2834 "off" | "disabled" | "false" => Some(ReasoningEffort::Off),
2835 "none" => Some(ReasoningEffort::Off), // Muse "none" maps to Off in our enum but display as "none"
2836 "minimal" | "minimum" => Some(ReasoningEffort::Minimal),
2837 "low" | "light" => Some(ReasoningEffort::Low),
2838 "medium" | "mid" => Some(ReasoningEffort::Medium),
2839 "high" => Some(ReasoningEffort::High),
2840 "xhigh" => Some(ReasoningEffort::XHigh),
2841 "ultra" | "ultracode" => Some(ReasoningEffort::Ultra),
2842 "max" | "maximum" => Some(ReasoningEffort::Max),
2843 "auto" | "automatic" | "adaptive" => Some(ReasoningEffort::Auto),
2844 "always_on" | "always-on" => Some(ReasoningEffort::Max),
2845 _ => None,
2846 }
2847 }
2848
2849 fn normalize_picker_effort(
2850 effort: ReasoningEffort,
2851 provider: ApiProvider,
2852 base_url: &str,
2853 wire_model: &str,
2854 model_is_auto: bool,
2855 ) -> ReasoningEffort {
2856 let normalized = if model_is_auto {
2857 effort
2858 } else {
2859 effort.normalize_for_route(provider, base_url, wire_model)
2860 };
2861 let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
2862 if efforts.contains(&normalized) {
2863 return normalized;
2864 }
2865 // Catalog-driven lists may keep Low/Medium that route normalization would
2866 // otherwise collapse. Prefer the operator's exact choice when the picker
2867 // still shows it.
2868 if efforts.contains(&effort) {
2869 return effort;
2870 }
2871 default_picker_effort(provider, &efforts)
2872 }
2873
2874 fn default_picker_effort(provider: ApiProvider, efforts: &[ReasoningEffort]) -> ReasoningEffort {
2875 let preferred = if provider == ApiProvider::OpenaiCodex {
2876 ReasoningEffort::Medium
2877 } else {
2878 ReasoningEffort::High
2879 };
2880 if efforts.contains(&preferred) {
2881 preferred
2882 } else {
2883 efforts
2884 .iter()
2885 .copied()
2886 .find(|effort| *effort != ReasoningEffort::Auto && *effort != ReasoningEffort::Off)
2887 .or_else(|| efforts.first().copied())
2888 .unwrap_or(preferred)
2889 }
2890 }
2891
2892 fn default_picker_effort_idx(
2893 provider: ApiProvider,
2894 base_url: &str,
2895 wire_model: &str,
2896 model_is_auto: bool,
2897 ) -> usize {
2898 let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
2899 let default_effort = default_picker_effort(provider, &efforts);
2900 efforts
2901 .iter()
2902 .position(|effort| *effort == default_effort)
2903 .unwrap_or(0)
2904 }
2905
2906 #[cfg(test)]
2907 mod tests {
2908 use super::*;
2909 use crate::tui::app::{App, TuiOptions};
2910 use std::path::PathBuf;
2911
2912 /// `_lock` bundles the process-wide test-env mutex with a guard that
2913 /// neutralizes the real Codex CLI OAuth login and model cache on disk. The
2914 /// picker must not inherit either the developer's auth state or live account
2915 /// roster unless a test opts into an isolated fixture explicitly.
2916 /// Declared in this order so the env var is restored (dropped first) while
2917 /// the mutex is still held, before the mutex itself is released.
2918 fn create_test_app() -> (
2919 App,
2920 Config,
2921 (
2922 Vec<crate::test_support::EnvVarGuard>,
2923 crate::test_support::TestEnvLock,
2924 ),
2925 ) {
2926 let lock = crate::test_support::lock_test_env();
2927 let mut env_guards = Vec::new();
2928 let mut seen = std::collections::HashSet::new();
2929 for provider in ApiProvider::sorted_for_display() {
2930 for &name in provider.env_vars() {
2931 if seen.insert(name) {
2932 env_guards.push(crate::test_support::EnvVarGuard::remove(name));
2933 }
2934 }
2935 }
2936 env_guards.push(crate::test_support::EnvVarGuard::set(
2937 "OPENAI_CODEX_AUTH_FILE",
2938 "/nonexistent/codewhale-test-codex-auth.json",
2939 ));
2940 env_guards.push(crate::test_support::EnvVarGuard::set(
2941 "CODEX_HOME",
2942 "/nonexistent/codewhale-test-codex-home",
2943 ));
2944 env_guards.push(crate::test_support::EnvVarGuard::set(
2945 "GROK_AUTH_PATH",
2946 "/nonexistent/codewhale-test-grok-auth.json",
2947 ));
2948 let options = TuiOptions {
2949 start_in_agent_mode: true,
2950 ..crate::test_support::test_tui_options(PathBuf::from("."))
2951 };
2952 let config = Config::default();
2953 let mut app = App::new(options, &config);
2954 // App::new merges in the user's persisted settings.toml, which can override
2955 // the model, effort, and provider with whatever the developer
2956 // happens to have saved. Pin all three back to known values so
2957 // the picker tests below exercise the picker logic, not the
2958 // user's environment. In particular `api_provider` matters because
2959 // pass-through providers (Ollama, OpenAI) hide the DeepSeek model
2960 // rows and leave only `auto` + custom — Down has nowhere to go.
2961 app.model = "deepseek-v4-pro".to_string();
2962 app.auto_model = false;
2963 app.reasoning_effort = ReasoningEffort::Max;
2964 app.reasoning_effort_preference = None;
2965 app.api_provider = crate::config::ApiProvider::Deepseek;
2966 app.model_ids_passthrough = false;
2967 app.provider_models.clear();
2968 app.enabled_provider_models.clear();
2969 app.pinned_models.clear();
2970 app.model_picker_memory = None;
2971 (app, config, (env_guards, lock))
2972 }
2973
2974 fn type_model_query(view: &mut ModelPickerView, query: &str) {
2975 for ch in query.chars() {
2976 view.handle_key(KeyEvent::new(
2977 KeyCode::Char(ch),
2978 crossterm::event::KeyModifiers::NONE,
2979 ));
2980 }
2981 }
2982
2983 fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String {
2984 (area.x..area.x.saturating_add(area.width))
2985 .map(|x| buf[(x, y)].symbol())
2986 .collect()
2987 }
2988
2989 fn row_containing(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> {
2990 (area.y..area.y.saturating_add(area.height))
2991 .find(|&y| buffer_row_text(buf, area, y).contains(needle))
2992 }
2993
2994 /// Every rendered model row in the pane, trimmed, in screen order.
2995 fn rendered_model_rows(view: &ModelPickerView, width: u16, height: u16) -> Vec<String> {
2996 let area = Rect::new(0, 0, width, height);
2997 let mut buf = Buffer::empty(area);
2998 view.render(area, &mut buf);
2999 let mut rows = Vec::new();
3000 for (rect, pane, _) in view.row_hitboxes.borrow().iter() {
3001 if *pane == Pane::Model {
3002 rows.push(buffer_row_text(&buf, *rect, rect.y).trim_end().to_string());
3003 }
3004 }
3005 rows
3006 }
3007
3008 /// The owner-facing bar for this surface: scanning the list must separate
3009 /// the DeepSeek family without leaving the picker.
3010 ///
3011 /// Rows used to be `label (hint)` where the hint was dropped whole when it
3012 /// did not fit — and it never fit, so a dozen DeepSeek routes rendered as
3013 /// nothing but their near-identical ids. This asserts the two failure modes
3014 /// that produced: rows that are byte-identical to each other, and rows that
3015 /// carry no metadata at all.
3016 #[test]
3017 fn deepseek_rows_render_distinguishably() {
3018 let (app, mut config, _lock) = create_test_app();
3019 config.api_key = Some("deepseek-picker-test-key".to_string());
3020 let mut view = ModelPickerView::new(&app, &config);
3021 view.view = ModelListView::Catalog;
3022 type_model_query(&mut view, "deepseek");
3023
3024 for (width, height) in [(120_u16, 40_u16), (100, 30), (80, 24)] {
3025 let rows = rendered_model_rows(&view, width, height);
3026 assert!(
3027 rows.len() > 2,
3028 "{width}x{height}: expected a populated DeepSeek list, got {rows:?}"
3029 );
3030
3031 // 1. No two visible rows may render as the same string.
3032 let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
3033 for row in &rows {
3034 *seen.entry(row.as_str()).or_default() += 1;
3035 }
3036 let collisions: Vec<_> = seen
3037 .iter()
3038 .filter(|(_, count)| **count > 1)
3039 .map(|(row, count)| format!("{count}x {row:?}"))
3040 .collect();
3041 assert!(
3042 collisions.is_empty(),
3043 "{width}x{height}: rows must be distinguishable, found duplicates: {collisions:?}"
3044 );
3045
3046 // 2. Every DeepSeek row must carry differentiating metadata beyond
3047 // the model id — a context window and its reasoning stance.
3048 for row in rows
3049 .iter()
3050 .filter(|row| row.to_lowercase().contains("deepseek"))
3051 {
3052 assert!(
3053 row.contains("reasoning"),
3054 "{width}x{height}: row lost its reasoning stance: {row:?}"
3055 );
3056 assert!(
3057 row.contains('M') || row.contains('K'),
3058 "{width}x{height}: row lost its context window: {row:?}"
3059 );
3060 }
3061 }
3062 }
3063
3064 /// Two providers may legitimately share a display name (`deepseek` and
3065 /// `deepseek-anthropic` are both spelled "DeepSeek"). The picker must not
3066 /// print two identical route labels for two different endpoints.
3067 #[test]
3068 fn same_named_deepseek_providers_get_distinct_route_labels() {
3069 let rows = [
3070 ModelPickerRow {
3071 id: "deepseek-v4-pro".to_string(),
3072 provider: Some(ApiProvider::Deepseek),
3073 provider_identity: None,
3074 hint: String::new(),
3075 metadata: EffectivePickerMetadata::default(),
3076 selectable: true,
3077 blocked_reason: None,
3078 enabled: true,
3079 },
3080 ModelPickerRow {
3081 id: "deepseek-v4-pro".to_string(),
3082 provider: Some(ApiProvider::DeepseekAnthropic),
3083 provider_identity: None,
3084 hint: String::new(),
3085 metadata: EffectivePickerMetadata::default(),
3086 selectable: true,
3087 blocked_reason: None,
3088 enabled: true,
3089 },
3090 ];
3091 assert_eq!(
3092 ApiProvider::Deepseek.display_name(),
3093 ApiProvider::DeepseekAnthropic.display_name(),
3094 "this test is only meaningful while the display names actually collide"
3095 );
3096
3097 let borrowed: Vec<&ModelPickerRow> = rows.iter().collect();
3098 let labels = route_labels_for_rows(&borrowed);
3099 let direct = labels.get("deepseek").expect("direct route label");
3100 let anthropic = labels
3101 .get("deepseek-anthropic")
3102 .expect("anthropic-dialect route label");
3103 assert_ne!(
3104 direct, anthropic,
3105 "colliding display names must be disambiguated"
3106 );
3107 assert_eq!(anthropic, "DeepSeek anthropic");
3108 }
3109
3110 /// A single provider needs no disambiguation noise.
3111 #[test]
3112 fn unique_route_labels_stay_bare_display_names() {
3113 let row = ModelPickerRow {
3114 id: "deepseek-v4-pro".to_string(),
3115 provider: Some(ApiProvider::Deepseek),
3116 provider_identity: None,
3117 hint: String::new(),
3118 metadata: EffectivePickerMetadata::default(),
3119 selectable: true,
3120 blocked_reason: None,
3121 enabled: true,
3122 };
3123 let borrowed = vec![&row];
3124 let labels = route_labels_for_rows(&borrowed);
3125 assert_eq!(labels.get("deepseek").map(String::as_str), Some("DeepSeek"));
3126 }
3127
3128 /// Metadata sheds whole chips rather than rendering a half-word fact.
3129 #[test]
3130 fn meta_chips_shed_whole_units_under_pressure() {
3131 let chips = vec![
3132 "1M".to_string(),
3133 "reasoning".to_string(),
3134 "missing key".to_string(),
3135 ];
3136 assert_eq!(fit_meta_chips(&chips, 40), "1M · reasoning · missing key");
3137 assert_eq!(fit_meta_chips(&chips, 20), "1M · reasoning");
3138 assert_eq!(fit_meta_chips(&chips, 5), "1M");
3139 // Below the minimum useful column nothing is rendered at all.
3140 assert_eq!(fit_meta_chips(&chips, 1), "");
3141 }
3142
3143 /// A model whose context window the registry does not know must render a
3144 /// blank column, never an invented number.
3145 #[test]
3146 fn unknown_context_window_is_omitted_not_guessed() {
3147 let row = ModelPickerRow {
3148 id: "some-unlisted-model".to_string(),
3149 provider: Some(ApiProvider::Deepseek),
3150 provider_identity: None,
3151 hint: String::new(),
3152 metadata: EffectivePickerMetadata {
3153 context_window: None,
3154 reasoning: true,
3155 ..EffectivePickerMetadata::default()
3156 },
3157 selectable: true,
3158 blocked_reason: None,
3159 enabled: true,
3160 };
3161 assert_eq!(model_row_meta_chips(&row), vec!["reasoning".to_string()]);
3162 }
3163
3164 #[test]
3165 fn model_picker_hint_uses_model_registry_metadata() {
3166 let hint = picker_model_hint("minimax/minimax-m3", None);
3167 assert!(
3168 hint.contains("1M ctx"),
3169 "hint should include registry context window: {hint}"
3170 );
3171 assert!(
3172 hint.contains("reasoning"),
3173 "hint should include registry reasoning support: {hint}"
3174 );
3175 // MiniMax-M3 ships without verified per-token pricing, so the hint
3176 // surfaces that honestly instead of inventing a rate.
3177 assert!(
3178 hint.contains("price unknown"),
3179 "hint should surface honest pricing availability: {hint}"
3180 );
3181
3182 // A priced registry row still surfaces its stated rate.
3183 let priced_hint = picker_model_hint("minimax/minimax-m2.7", None);
3184 assert!(
3185 priced_hint.contains("priced")
3186 || priced_hint.contains("per Mtok")
3187 || priced_hint.contains("$"),
3188 "hint should include pricing for a priced row: {priced_hint}"
3189 );
3190 }
3191
3192 #[test]
3193 fn auto_picker_hint_discloses_scope_data_path_and_last_route() {
3194 let (mut app, config, _lock) = create_test_app();
3195 app.ui_locale = Locale::En;
3196 app.last_effective_provider = Some(ApiProvider::Zai);
3197 app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
3198
3199 let local = auto_picker_hint(&app, &config);
3200 assert!(local.contains("local heuristic"), "{local}");
3201 assert!(local.contains("no router request"), "{local}");
3202 assert!(
3203 local.contains("last Zhipu AI / Z.ai · GLM-5-Turbo"),
3204 "{local}"
3205 );
3206
3207 // A provider key is not a request for a network classifier. Holding a
3208 // DeepSeek key used to silently elect `deepseek-v4-flash` for every
3209 // Auto turn; the hint must keep saying "local heuristic" so the
3210 // disclosure matches what actually runs.
3211 let _deepseek =
3212 crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "test-router-key");
3213 let still_local = auto_picker_hint(&app, &config);
3214 assert!(still_local.contains("local heuristic"), "{still_local}");
3215 assert!(still_local.contains("no router request"), "{still_local}");
3216 assert!(!still_local.contains("test-router-key"), "{still_local}");
3217
3218 // …an explicit `[auto.router]` turns the classifier on, and #4411
3219 // keeps its default scope confined to the active provider — the hint
3220 // must not advertise the wider "runnable providers" scope.
3221 let mut routed = config.clone();
3222 routed.auto = Some(crate::config::AutoConfig {
3223 cost_saving: None,
3224 router: Some(crate::config::AutoRouterConfig {
3225 provider: Some("deepseek".to_string()),
3226 model: Some("deepseek-v4-flash".to_string()),
3227 thinking: None,
3228 }),
3229 cross_provider: None,
3230 });
3231 let network = auto_picker_hint(&app, &routed);
3232 assert!(network.contains("active provider only"), "{network}");
3233 assert!(!network.contains("runnable providers"), "{network}");
3234
3235 // Only the persisted `[auto] cross_provider = true` opt-in widens it.
3236 let mut widened = routed.clone();
3237 if let Some(auto) = widened.auto.as_mut() {
3238 auto.cross_provider = Some(true);
3239 }
3240 let network = auto_picker_hint(&app, &widened);
3241 assert!(network.contains("runnable providers"), "{network}");
3242 assert!(network.contains("request + recent context"), "{network}");
3243 assert!(
3244 network.contains("DeepSeek / deepseek-v4-flash"),
3245 "{network}"
3246 );
3247 assert!(!network.contains("test-router-key"), "{network}");
3248
3249 // A scope opt-in without an explicit `[auto.router]` widens which
3250 // candidates Auto may pick but elects no network classifier — the
3251 // implicit DeepSeek-flash default is gone, so the hint stays local.
3252 let opted_in = Config {
3253 auto: Some(crate::config::AutoConfig {
3254 cost_saving: None,
3255 cross_provider: Some(true),
3256 router: None,
3257 }),
3258 ..config.clone()
3259 };
3260 let scope_only = auto_picker_hint(&app, &opted_in);
3261 assert!(scope_only.contains("local heuristic"), "{scope_only}");
3262 assert!(scope_only.contains("no router request"), "{scope_only}");
3263 }
3264
3265 #[test]
3266 fn kimi_k3_rows_name_their_routes_and_plan_floor() {
3267 let config = Config::default();
3268
3269 // Bare `k3` (Kimi Code membership): route-labeled, and the default
3270 // 262K window is called out as the plan-tier floor with the raise
3271 // path, so two K3 rows never read as an unexplained duplicate.
3272 let membership = effective_picker_metadata(&config, Some(ApiProvider::Moonshot), "k3");
3273 assert_eq!(
3274 membership.context_window,
3275 Some(crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS)
3276 );
3277 let membership_hint =
3278 render_picker_model_hint("k3", Some(ApiProvider::Moonshot), &membership, None);
3279 assert!(
3280 membership_hint.contains("Kimi Code plan route"),
3281 "{membership_hint}"
3282 );
3283 assert!(
3284 membership_hint.contains("262K ctx (plan floor; raise via context_window)"),
3285 "{membership_hint}"
3286 );
3287
3288 // `kimi-k3` (direct open platform): route-labeled with its 1M window.
3289 let direct = effective_picker_metadata(&config, Some(ApiProvider::Moonshot), "kimi-k3");
3290 assert_eq!(
3291 direct.context_window,
3292 Some(crate::models::KIMI_K3_CONTEXT_WINDOW_TOKENS)
3293 );
3294 let direct_hint =
3295 render_picker_model_hint("kimi-k3", Some(ApiProvider::Moonshot), &direct, None);
3296 assert!(
3297 direct_hint.contains("Moonshot direct route"),
3298 "{direct_hint}"
3299 );
3300 assert!(direct_hint.contains("1.05M ctx"), "{direct_hint}");
3301 assert!(
3302 !direct_hint.contains("plan floor"),
3303 "the direct route window is not plan-dependent: {direct_hint}"
3304 );
3305
3306 // An explicit plan-tier override drops the floor annotation.
3307 let mut override_config = Config::default();
3308 override_config
3309 .providers
3310 .get_or_insert_with(Default::default)
3311 .moonshot
3312 .context_window = Some(1_048_576);
3313 let upgraded =
3314 effective_picker_metadata(&override_config, Some(ApiProvider::Moonshot), "k3");
3315 assert_eq!(upgraded.context_window, Some(1_048_576));
3316 let upgraded_hint =
3317 render_picker_model_hint("k3", Some(ApiProvider::Moonshot), &upgraded, None);
3318 assert!(upgraded_hint.contains("1.05M ctx"), "{upgraded_hint}");
3319 assert!(
3320 !upgraded_hint.contains("plan floor"),
3321 "configured entitlement must not still read as the floor: {upgraded_hint}"
3322 );
3323 }
3324
3325 #[test]
3326 fn same_model_id_uses_route_effective_api_and_oauth_metadata() {
3327 let config = Config::default();
3328 let api = effective_picker_metadata(&config, Some(ApiProvider::Openai), "gpt-5.5");
3329 let codex_cache = CodexModelMetadata {
3330 id: "gpt-5.5".to_string(),
3331 context_window: Some(272_000),
3332 reasoning: Some(true),
3333 };
3334 let oauth = effective_picker_metadata_with_codex(
3335 &config,
3336 Some(ApiProvider::OpenaiCodex),
3337 "gpt-5.5",
3338 Some(&codex_cache),
3339 );
3340
3341 assert_eq!(api.context_window, Some(1_050_000));
3342 assert_eq!(api.max_output, Some(128_000));
3343 assert!(matches!(api.pricing, PickerPricing::Known(_)));
3344 assert_eq!(oauth.context_window, Some(272_000));
3345 assert_eq!(oauth.max_output, None);
3346 assert_eq!(oauth.pricing, PickerPricing::Unavailable);
3347 assert_eq!(oauth.tool_calls, Some(true));
3348 assert!(oauth.reasoning);
3349
3350 let api_hint = render_picker_model_hint("gpt-5.5", Some(ApiProvider::Openai), &api, None);
3351 let oauth_hint = render_picker_model_hint(
3352 "gpt-5.5",
3353 Some(ApiProvider::OpenaiCodex),
3354 &oauth,
3355 Some(CodexModelCacheFreshness::Fresh),
3356 );
3357 assert!(api_hint.contains("1.05M ctx"), "{api_hint}");
3358 assert!(api_hint.contains("128K out"), "{api_hint}");
3359 assert!(
3360 api_hint.contains("priced") || api_hint.contains('$') || api_hint.contains("per Mtok"),
3361 "{api_hint}"
3362 );
3363 assert!(
3364 oauth_hint.contains("272K ctx · ChatGPT route"),
3365 "OAuth ctx must be labeled route-scoped (TUI-DOG-016): {oauth_hint}"
3366 );
3367 assert!(oauth_hint.contains("tools"), "{oauth_hint}");
3368 assert!(oauth_hint.contains("ChatGPT OAuth"), "{oauth_hint}");
3369 for false_api_fact in ["1.05M", "128K out", "priced", "$", "per Mtok"] {
3370 assert!(
3371 !oauth_hint.contains(false_api_fact),
3372 "OAuth hint inherited API-only fact {false_api_fact:?}: {oauth_hint}"
3373 );
3374 }
3375 }
3376
3377 #[test]
3378 fn provider_context_override_wins_in_picker_metadata() {
3379 let config = Config {
3380 providers: Some(crate::config::ProvidersConfig {
3381 openai: crate::config::ProviderConfig {
3382 context_window: Some(123_456),
3383 ..Default::default()
3384 },
3385 ..Default::default()
3386 }),
3387 ..Config::default()
3388 };
3389
3390 let metadata = effective_picker_metadata(&config, Some(ApiProvider::Openai), "gpt-5.5");
3391
3392 assert_eq!(metadata.context_window, Some(123_456));
3393 }
3394
3395 #[test]
3396 fn codex_cache_roster_populates_picker_and_preserves_custom_selection() {
3397 let (mut app, config, _lock) = create_test_app();
3398 let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
3399 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
3400 let cache = serde_json::json!({
3401 "fetched_at": chrono::Utc::now(),
3402 "models": [
3403 {"slug": "gpt-fixture-secondary", "priority": 20, "visibility": "list", "context_window": 128000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "medium"}]},
3404 {"slug": "gpt-fixture-primary", "priority": 10, "visibility": "list", "context_window": 372000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "high"}]},
3405 {"slug": "codex-fixture-review", "priority": 30, "visibility": "hide", "context_window": 272000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "medium"}]}
3406 ]
3407 });
3408 std::fs::write(
3409 codex_home.path().join("models_cache.json"),
3410 serde_json::to_vec_pretty(&cache).expect("serialize cache"),
3411 )
3412 .expect("write cache");
3413 app.api_provider = ApiProvider::OpenaiCodex;
3414 app.model = "gpt-private-preview".to_string();
3415 app.auto_model = false;
3416
3417 let view = ModelPickerView::new(&app, &config);
3418 let codex_ids: Vec<_> = view
3419 .model_rows
3420 .iter()
3421 .filter(|row| row.provider == Some(ApiProvider::OpenaiCodex))
3422 .map(|row| row.id.as_str())
3423 .collect();
3424
3425 assert_eq!(
3426 codex_ids,
3427 [
3428 "gpt-fixture-primary",
3429 "gpt-fixture-secondary",
3430 "codex-fixture-review"
3431 ]
3432 );
3433 assert!(view.show_custom_model_row);
3434 assert_eq!(view.resolved_model(), "gpt-private-preview");
3435 assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
3436 let primary = view
3437 .model_rows
3438 .iter()
3439 .find(|row| row.id == "gpt-fixture-primary")
3440 .expect("primary row");
3441 let secondary = view
3442 .model_rows
3443 .iter()
3444 .find(|row| row.id == "gpt-fixture-secondary")
3445 .expect("secondary row");
3446 assert!(primary.hint.contains("372K ctx"), "{}", primary.hint);
3447 assert!(secondary.hint.contains("128K ctx"), "{}", secondary.hint);
3448 assert!(
3449 !secondary.hint.contains("no tools"),
3450 "parallel=false must not be misread as no tool support: {}",
3451 secondary.hint
3452 );
3453 }
3454
3455 #[test]
3456 fn saved_codex_model_outside_fresh_roster_is_explicitly_unconfirmed() {
3457 let (mut app, config, _lock) = create_test_app();
3458 let codex_home = tempfile::tempdir().expect("Codex home");
3459 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
3460 std::fs::write(
3461 codex_home.path().join("models_cache.json"),
3462 serde_json::to_vec(&serde_json::json!({
3463 "fetched_at": chrono::Utc::now(),
3464 "models": [{
3465 "slug": "gpt-roster-confirmed",
3466 "priority": 1,
3467 "context_window": 272000,
3468 "supported_reasoning_levels": [{"effort": "high"}]
3469 }]
3470 }))
3471 .expect("serialize cache"),
3472 )
3473 .expect("write cache");
3474 app.provider_models.insert(
3475 ApiProvider::OpenaiCodex.as_str().to_string(),
3476 "gpt-saved-unconfirmed".to_string(),
3477 );
3478
3479 let view = ModelPickerView::new(&app, &config);
3480 let row = view
3481 .model_rows
3482 .iter()
3483 .find(|row| {
3484 row.provider == Some(ApiProvider::OpenaiCodex) && row.id == "gpt-saved-unconfirmed"
3485 })
3486 .expect("saved Codex row");
3487
3488 assert!(
3489 row.hint.contains("OAuth roster unconfirmed"),
3490 "{}",
3491 row.hint
3492 );
3493 for unsourced in ["ctx", "tools", "reasoning", "priced", "$", "per Mtok"] {
3494 assert!(
3495 !row.hint.contains(unsourced),
3496 "unconfirmed row inherited {unsourced:?}: {}",
3497 row.hint
3498 );
3499 }
3500 }
3501
3502 #[test]
3503 fn cross_provider_codex_row_previews_destination_route_truth() {
3504 let (app, config, _lock) = create_test_app();
3505 let codex_home = tempfile::tempdir().expect("Codex home");
3506 let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
3507 let view = ModelPickerView::new(&app, &config);
3508 let row = view
3509 .model_rows
3510 .iter()
3511 .find(|row| {
3512 row.provider == Some(ApiProvider::OpenaiCodex)
3513 && row.id == crate::config::DEFAULT_OPENAI_CODEX_MODEL
3514 })
3515 .expect("Codex fallback row");
3516
3517 assert!(
3518 row.hint
3519 .contains("switch route · missing login · OAuth roster missing · fallback"),
3520 "{}",
3521 row.hint
3522 );
3523 assert!(!row.hint.contains(" ctx"), "{}", row.hint);
3524 assert!(!row.hint.contains("tools"), "{}", row.hint);
3525 assert!(!row.hint.contains("1.05M"), "{}", row.hint);
3526 assert!(!row.hint.contains("128K out"), "{}", row.hint);
3527 assert!(!row.hint.contains("priced"), "{}", row.hint);
3528 }
3529
3530 #[test]
3531 fn configured_failed_provider_models_remain_visible_with_health_reason() {
3532 let (mut app, mut config, _lock) = create_test_app();
3533 config.providers = Some(crate::config::ProvidersConfig {
3534 zai: crate::config::ProviderConfig {
3535 api_key: Some("zai-test-key".to_string()),
3536 ..Default::default()
3537 },
3538 ..Default::default()
3539 });
3540 app.provider_health.record_failure_message(
3541 &config,
3542 ApiProvider::Zai,
3543 crate::config::ZAI_GLM_5_2_MODEL,
3544 crate::error_taxonomy::ErrorCategory::Authentication,
3545 "test credential rejected",
3546 );
3547
3548 let view = ModelPickerView::new(&app, &config);
3549 let row = view
3550 .model_rows
3551 .iter()
3552 .find(|row| {
3553 row.provider == Some(ApiProvider::Zai) && row.id == crate::config::ZAI_GLM_5_2_MODEL
3554 })
3555 .expect("configured Z.ai GLM route remains listed");
3556 assert!(row.hint.contains("last check failed (authentication)"));
3557 }
3558
3559 #[test]
3560 fn non_active_configured_private_model_is_listed_once_and_selectable() {
3561 let (app, mut config, _lock) = create_test_app();
3562 let private_model = "private/acme-code-2027";
3563 assert!(
3564 !provider_catalog_model_ids(ApiProvider::Openrouter)
3565 .iter()
3566 .any(|model| model == private_model),
3567 "fixture must stay outside the bundled/live catalog"
3568 );
3569 assert!(!app.provider_models.contains_key("openrouter"));
3570 config.providers = Some(crate::config::ProvidersConfig {
3571 openrouter: crate::config::ProviderConfig {
3572 api_key: Some("openrouter-picker-test-key".to_string()),
3573 model: Some(private_model.to_string()),
3574 ..Default::default()
3575 },
3576 ..Default::default()
3577 });
3578
3579 let mut view = ModelPickerView::new(&app, &config);
3580 let matches = view
3581 .model_rows
3582 .iter()
3583 .filter(|row| row.provider == Some(ApiProvider::Openrouter) && row.id == private_model)
3584 .collect::<Vec<_>>();
3585 assert_eq!(matches.len(), 1, "configured private model must be deduped");
3586 assert!(matches[0].selectable, "{}", matches[0].hint);
3587
3588 view.query = private_model.to_string();
3589 view.selected_model_idx = view
3590 .visible_model_rows()
3591 .iter()
3592 .position(|row| {
3593 row.provider == Some(ApiProvider::Openrouter) && row.id == private_model
3594 })
3595 .expect("configured private model remains searchable");
3596 assert!(matches!(
3597 view.handle_key(KeyEvent::new(
3598 KeyCode::Enter,
3599 crossterm::event::KeyModifiers::NONE,
3600 )),
3601 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
3602 provider: Some(ApiProvider::Openrouter),
3603 ..
3604 })
3605 ));
3606 }
3607
3608 #[test]
3609 fn missing_login_and_key_rows_are_visible_but_inert() {
3610 let (app, config, _lock) = create_test_app();
3611 for (provider, expected_readiness) in [
3612 (ApiProvider::Openrouter, "missing key"),
3613 (ApiProvider::OpenaiCodex, "missing login"),
3614 ] {
3615 let mut view = ModelPickerView::new(&app, &config);
3616 view.query = provider.as_str().to_string();
3617 view.selected_model_idx = view
3618 .visible_model_rows()
3619 .iter()
3620 .position(|row| row.provider == Some(provider))
3621 .expect("unready route remains visible");
3622 let row = view.visible_model_rows()[view.selected_model_idx];
3623 assert!(row.hint.contains(expected_readiness), "{}", row.hint);
3624 assert!(!row.selectable, "{}", row.hint);
3625 // v0.9.1: Enter explains the lock instead of silently no-op'ing.
3626 assert!(matches!(
3627 view.handle_key(KeyEvent::new(
3628 KeyCode::Enter,
3629 crossterm::event::KeyModifiers::NONE,
3630 )),
3631 ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth { .. })
3632 | ViewAction::Emit(ViewEvent::StatusMessage { .. })
3633 ));
3634 }
3635 }
3636
3637 #[test]
3638 fn invalid_candidate_is_visible_but_enter_explains() {
3639 let (mut app, mut config, _lock) = create_test_app();
3640 config.api_key = Some("deepseek-test-key".to_string());
3641 config.providers = Some(crate::config::ProvidersConfig {
3642 deepseek: crate::config::ProviderConfig {
3643 model: Some("anthropic/claude-foreign".to_string()),
3644 ..Default::default()
3645 },
3646 ..Default::default()
3647 });
3648 app.api_provider = ApiProvider::Deepseek;
3649 app.model = "anthropic/claude-foreign".to_string();
3650 assert!(
3651 !crate::provider_readiness::route_is_valid_for_model(
3652 &config,
3653 ApiProvider::Deepseek,
3654 None,
3655 ),
3656 "fixture must begin with an invalid saved route"
3657 );
3658 let mut view = ModelPickerView::new(&app, &config);
3659 view.selected_model_idx = view
3660 .visible_model_rows()
3661 .iter()
3662 .position(|row| {
3663 row.provider == Some(ApiProvider::Deepseek) && row.id == "anthropic/claude-foreign"
3664 })
3665 .expect("invalid configured model remains visible as an inert provider row");
3666 assert!(!view.selected_model_is_selectable());
3667 // Locked/invalid rows explain on Enter rather than applying or
3668 // silently ignoring the keystroke.
3669 assert!(matches!(
3670 view.handle_key(KeyEvent::new(
3671 KeyCode::Enter,
3672 crossterm::event::KeyModifiers::NONE,
3673 )),
3674 ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth { .. })
3675 | ViewAction::Emit(ViewEvent::StatusMessage { .. })
3676 ));
3677 }
3678
3679 #[test]
3680 fn valid_catalog_model_can_repair_an_invalid_saved_model() {
3681 let (mut app, mut config, _lock) = create_test_app();
3682 config.api_key = Some("deepseek-test-key".to_string());
3683 config.providers = Some(crate::config::ProvidersConfig {
3684 deepseek: crate::config::ProviderConfig {
3685 model: Some("anthropic/claude-foreign".to_string()),
3686 ..Default::default()
3687 },
3688 ..Default::default()
3689 });
3690 app.api_provider = ApiProvider::Deepseek;
3691 app.model = "anthropic/claude-foreign".to_string();
3692 assert!(
3693 !crate::provider_readiness::route_is_valid_for_model(
3694 &config,
3695 ApiProvider::Deepseek,
3696 None,
3697 ),
3698 "fixture must begin with an invalid saved model"
3699 );
3700 let mut view = ModelPickerView::new(&app, &config);
3701 view.query = "deepseek-v4-pro".to_string();
3702 view.selected_model_idx = view
3703 .visible_model_rows()
3704 .iter()
3705 .position(|row| {
3706 row.provider == Some(ApiProvider::Deepseek) && row.id == "deepseek-v4-pro"
3707 })
3708 .expect("valid DeepSeek catalog row");
3709 let selected = view.visible_model_rows()[view.selected_model_idx];
3710 assert!(selected.selectable, "{}", selected.hint);
3711 assert!(
3712 !selected.hint.contains("invalid route"),
3713 "{}",
3714 selected.hint
3715 );
3716 assert!(matches!(
3717 view.handle_key(KeyEvent::new(
3718 KeyCode::Enter,
3719 crossterm::event::KeyModifiers::NONE,
3720 )),
3721 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { .. })
3722 ));
3723 }
3724
3725 #[test]
3726 fn provider_query_splits_support_colon_and_space_forms() {
3727 assert_eq!(
3728 provider_query_splits("openrouter:anthropic/claude-sonnet-4"),
3729 vec![("openrouter", "anthropic/claude-sonnet-4")]
3730 );
3731 assert_eq!(
3732 provider_query_splits("openrouter anthropic/claude-sonnet-4"),
3733 vec![("openrouter", "anthropic/claude-sonnet-4")]
3734 );
3735 assert_eq!(
3736 provider_query_splits("openrouter anthropic/foo:bar"),
3737 vec![
3738 ("openrouter anthropic/foo", "bar"),
3739 ("openrouter", "anthropic/foo:bar")
3740 ]
3741 );
3742 }
3743
3744 #[test]
3745 fn picker_main_rows_include_saved_choices_with_provider_identity() {
3746 let (mut app, config, _lock) = create_test_app();
3747 app.api_provider = crate::config::ApiProvider::Together;
3748 app.model = crate::config::DEFAULT_TOGETHER_MODEL.to_string();
3749 app.provider_models.insert(
3750 "openrouter".to_string(),
3751 crate::config::DEFAULT_OPENROUTER_MODEL.to_string(),
3752 );
3753
3754 let view = ModelPickerView::new(&app, &config);
3755
3756 let saved = view
3757 .visible_model_rows()
3758 .into_iter()
3759 .find(|row| {
3760 row.provider == Some(crate::config::ApiProvider::Openrouter)
3761 && row.id == crate::config::DEFAULT_OPENROUTER_MODEL
3762 })
3763 .expect("saved OpenRouter choice should migrate into the ordinary list");
3764 // The id owns the first column and the route its own second column, so
3765 // a cross-provider choice is no longer a single prefixed string.
3766 assert_eq!(saved.id, crate::config::DEFAULT_OPENROUTER_MODEL);
3767 let rows = view.visible_model_rows();
3768 assert_eq!(
3769 route_labels_for_rows(&rows)
3770 .get("openrouter")
3771 .map(String::as_str),
3772 Some(crate::config::ApiProvider::Openrouter.display_name())
3773 );
3774 }
3775
3776 #[test]
3777 fn picker_default_view_requires_an_enabled_model_not_just_a_configured_provider() {
3778 // Provider setup and model addition are separate decisions: a bare
3779 // `[providers.together]` route does not flood the ordinary chooser
3780 // with Together's catalog.
3781 let (mut app, _default_config, _lock) = create_test_app();
3782 app.api_provider = crate::config::ApiProvider::Deepseek;
3783 app.model = "deepseek-v4-pro".to_string();
3784 app.auto_model = false;
3785
3786 let config = Config {
3787 providers: Some(crate::config::ProvidersConfig {
3788 together: crate::config::ProviderConfig {
3789 base_url: Some("https://custom.together.example/v1".to_string()),
3790 ..Default::default()
3791 },
3792 ..Default::default()
3793 }),
3794 ..Config::default()
3795 };
3796 let view = ModelPickerView::new(&app, &config);
3797 let visible_ids = view.visible_model_ids();
3798
3799 assert!(
3800 view.visible_model_rows()
3801 .iter()
3802 .all(|row| row.provider != Some(crate::config::ApiProvider::Together)),
3803 "configured provider without an enabled model leaked catalog rows: {visible_ids:?}"
3804 );
3805 // Auto and the active provider's own rows are still present.
3806 assert!(visible_ids.contains(&"auto"));
3807 assert!(visible_ids.contains(&"deepseek-v4-pro"));
3808
3809 let mut enabled_app = app;
3810 enabled_app.enable_provider_model(
3811 crate::config::ApiProvider::Together.as_str(),
3812 crate::config::DEFAULT_TOGETHER_MODEL,
3813 );
3814 let enabled = ModelPickerView::new(&enabled_app, &config);
3815 assert!(
3816 enabled
3817 .visible_model_ids()
3818 .contains(&crate::config::DEFAULT_TOGETHER_MODEL),
3819 "explicitly enabled Together model should join the ordinary chooser"
3820 );
3821 }
3822
3823 #[test]
3824 fn picker_default_view_excludes_self_hosted_provider_without_explicit_setup() {
3825 // #3830: `has_api_key_for` reports `true` unconditionally for
3826 // self-hosted providers (no auth required to route to them) — that
3827 // alone must not surface Sglang/Vllm in the default view for every
3828 // user. Sglang (unlike Ollama) has real catalog model ids, so it's a
3829 // meaningful row to check rather than an empty contribution.
3830 let (mut app, _default_config, _lock) = create_test_app();
3831 app.api_provider = crate::config::ApiProvider::Deepseek;
3832 app.model = "deepseek-v4-pro".to_string();
3833 app.auto_model = false;
3834 let config = Config::default();
3835
3836 let view = ModelPickerView::new(&app, &config);
3837 assert!(
3838 !view
3839 .visible_model_rows()
3840 .iter()
3841 .any(|row| row.provider == Some(crate::config::ApiProvider::Sglang)),
3842 "self-hosted Sglang has no explicit setup and isn't active"
3843 );
3844
3845 // Discoverability is preserved: typing a query still reveals it.
3846 let mut queried = ModelPickerView::new(&app, &config);
3847 type_model_query(&mut queried, "sglang");
3848 assert!(
3849 queried
3850 .visible_model_rows()
3851 .iter()
3852 .any(|row| row.provider == Some(crate::config::ApiProvider::Sglang)),
3853 "searching should still surface unconfigured providers"
3854 );
3855 }
3856
3857 #[test]
3858 fn picker_configured_view_ignores_empty_anthropic_header_table() {
3859 let (mut app, _default_config, _lock) = create_test_app();
3860 app.api_provider = crate::config::ApiProvider::Deepseek;
3861 app.model = "deepseek-v4-pro".to_string();
3862 app.auto_model = false;
3863 let config = Config {
3864 providers: Some(crate::config::ProvidersConfig {
3865 anthropic: crate::config::ProviderConfig {
3866 http_headers: Some(std::collections::HashMap::new()),
3867 ..Default::default()
3868 },
3869 ..Default::default()
3870 }),
3871 ..Config::default()
3872 };
3873 let view = ModelPickerView::new(&app, &config);
3874 assert!(
3875 !view
3876 .visible_model_rows()
3877 .iter()
3878 .any(|row| row.provider == Some(crate::config::ApiProvider::Anthropic)),
3879 "empty persisted headers must not pull Anthropic into Configured"
3880 );
3881
3882 let mut queried = ModelPickerView::new(&app, &config);
3883 type_model_query(&mut queried, "anthropic");
3884 assert!(
3885 queried
3886 .visible_model_rows()
3887 .iter()
3888 .any(|row| row.provider == Some(crate::config::ApiProvider::Anthropic)),
3889 "full-catalog search must still discover unconfigured Anthropic routes"
3890 );
3891 }
3892
3893 #[test]
3894 fn custom_model_row_position_accounts_for_other_configured_providers() {
3895 // #3830 regression: `resolved_model`/`model_row_count` treat any
3896 // selection at or past `visible_model_rows().len()` as "the custom
3897 // row." Once other configured providers' rows are mixed into the
3898 // default view, the initial selection must still land past *all* of
3899 // them, not just past the active provider's own rows.
3900 let (mut app, _default_config, _lock) = create_test_app();
3901 app.api_provider = crate::config::ApiProvider::Deepseek;
3902 app.model = "deepseek-v4-pro-2026-04-XX".to_string();
3903 app.auto_model = false;
3904
3905 let config = Config {
3906 providers: Some(crate::config::ProvidersConfig {
3907 together: crate::config::ProviderConfig {
3908 base_url: Some("https://custom.together.example/v1".to_string()),
3909 ..Default::default()
3910 },
3911 ..Default::default()
3912 }),
3913 ..Config::default()
3914 };
3915 app.enable_provider_model(
3916 crate::config::ApiProvider::Together.as_str(),
3917 crate::config::DEFAULT_TOGETHER_MODEL,
3918 );
3919
3920 let view = ModelPickerView::new(&app, &config);
3921 assert!(view.show_custom_model_row);
3922 assert!(
3923 view.visible_model_rows()
3924 .iter()
3925 .any(|row| row.provider == Some(crate::config::ApiProvider::Together)),
3926 "sanity check: Together rows are actually in the default view"
3927 );
3928 assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
3929 assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
3930 }
3931
3932 #[test]
3933 fn picker_initial_selection_matches_app_state() {
3934 let (mut app, config, _lock) = create_test_app();
3935 app.model = "deepseek-v4-flash".to_string();
3936 app.auto_model = false;
3937 app.reasoning_effort = ReasoningEffort::Max;
3938 let view = ModelPickerView::new(&app, &config);
3939 assert_eq!(view.resolved_model(), "deepseek-v4-flash");
3940 assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
3941 }
3942
3943 #[test]
3944 fn picker_keeps_active_model_selected_after_pinned_row_reordering() {
3945 let (mut app, config, _lock) = create_test_app();
3946 app.model = "deepseek-v4-pro".to_string();
3947 app.auto_model = false;
3948 app.enable_provider_model(ApiProvider::Deepseek.as_str(), "deepseek-v4-flash");
3949 app.pinned_models = vec![PinnedModel {
3950 provider: ApiProvider::Deepseek.as_str().to_string(),
3951 model: "deepseek-v4-flash".to_string(),
3952 label: None,
3953 }];
3954
3955 let view = ModelPickerView::new(&app, &config);
3956
3957 assert!(!view.show_custom_model_row);
3958 assert_eq!(view.resolved_model(), "deepseek-v4-pro");
3959 assert_eq!(
3960 view.visible_model_rows()[view.selected_model_idx].id,
3961 "deepseek-v4-pro",
3962 "the visible highlight must stay on the active model after pins reorder rows"
3963 );
3964 }
3965
3966 #[test]
3967 fn muse_session_can_select_deepseek_flash_without_provider_first() {
3968 let (mut app, config, _lock) = create_test_app();
3969 app.api_provider = crate::config::ApiProvider::Meta;
3970 app.model = "muse-spark-1.1".to_string();
3971 app.auto_model = false;
3972
3973 let mut view = ModelPickerView::new(&app, &config);
3974 assert_eq!(view.view, ModelListView::Configured);
3975 type_model_query(&mut view, "deepseek v4 flash");
3976 let flash = view
3977 .visible_model_rows()
3978 .iter()
3979 .position(|row| {
3980 row.id == "deepseek-v4-flash"
3981 && row.provider == Some(crate::config::ApiProvider::Deepseek)
3982 })
3983 .expect("typing a model name searches every provider");
3984 view.selected_model_idx = flash;
3985
3986 assert_eq!(view.resolved_model(), "deepseek-v4-flash");
3987 assert_eq!(
3988 view.resolved_provider(),
3989 Some(crate::config::ApiProvider::Deepseek)
3990 );
3991 assert!(matches!(
3992 view.build_event(),
3993 ViewEvent::ModelPickerApplied {
3994 provider: Some(crate::config::ApiProvider::Deepseek),
3995 ..
3996 }
3997 ));
3998 }
3999
4000 #[test]
4001 fn stale_deepseek_alias_is_migrated_out_of_picker_choices() {
4002 for provider in [
4003 crate::config::ApiProvider::Deepseek,
4004 crate::config::ApiProvider::DeepseekCN,
4005 crate::config::ApiProvider::DeepseekAnthropic,
4006 ] {
4007 let (mut app, config, _lock) = create_test_app();
4008 app.api_provider = provider;
4009 app.model = "deepseek-reasoner".to_string();
4010 app.auto_model = false;
4011 app.provider_models.insert(
4012 provider.as_str().to_string(),
4013 "deepseek-reasoner".to_string(),
4014 );
4015
4016 let view = ModelPickerView::new(&app, &config);
4017 let ids = view.visible_model_ids();
4018 assert!(ids.contains(&"deepseek-v4-flash"), "{provider:?}");
4019 assert!(!ids.contains(&"deepseek-chat"), "{provider:?}");
4020 assert!(!ids.contains(&"deepseek-reasoner"), "{provider:?}");
4021 assert_eq!(view.resolved_model(), "deepseek-v4-flash");
4022 assert!(matches!(
4023 view.build_event(),
4024 ViewEvent::ModelPickerApplied {
4025 model,
4026 previous_model,
4027 ..
4028 } if model == "deepseek-v4-flash" && previous_model == "deepseek-reasoner"
4029 ));
4030
4031 let completions = provider_scoped_model_completion_ids(&app);
4032 assert!(completions.iter().any(|id| id == "deepseek-v4-flash"));
4033 assert!(!completions.iter().any(|id| id == "deepseek-chat"));
4034 assert!(!completions.iter().any(|id| id == "deepseek-reasoner"));
4035 }
4036 }
4037
4038 #[test]
4039 fn provider_native_reasoner_id_is_not_globally_rewritten() {
4040 assert_eq!(
4041 picker_visible_model_id(
4042 crate::config::ApiProvider::WanjieArk,
4043 "deepseek-reasoner",
4044 false,
4045 ),
4046 "deepseek-reasoner"
4047 );
4048 }
4049
4050 #[test]
4051 fn custom_deepseek_endpoint_keeps_provider_owned_alias_in_picker() {
4052 let (mut app, mut config, _lock) = create_test_app();
4053 config.provider = Some("deepseek".to_string());
4054 config.base_url = Some("https://models.example/v1".to_string());
4055 app.api_provider = crate::config::ApiProvider::Deepseek;
4056 app.model_ids_passthrough = config.model_ids_pass_through();
4057 app.model = "deepseek-reasoner".to_string();
4058 app.auto_model = false;
4059 app.provider_models
4060 .insert("deepseek".to_string(), "deepseek-reasoner".to_string());
4061
4062 let view = ModelPickerView::new(&app, &config);
4063 let ids = view.visible_model_ids();
4064
4065 assert!(ids.contains(&"deepseek-reasoner"), "{ids:?}");
4066 assert_eq!(view.resolved_model(), "deepseek-reasoner");
4067 assert!(matches!(
4068 view.build_event(),
4069 ViewEvent::ModelPickerApplied { model, .. } if model == "deepseek-reasoner"
4070 ));
4071 let completions = provider_scoped_model_completion_ids(&app);
4072 assert!(completions.iter().any(|id| id == "deepseek-reasoner"));
4073 }
4074
4075 #[test]
4076 fn picker_initial_selection_matches_auto_state() {
4077 let (mut app, config, _lock) = create_test_app();
4078 app.model = "auto".to_string();
4079 app.auto_model = true;
4080 app.reasoning_effort = ReasoningEffort::Auto;
4081
4082 let view = ModelPickerView::new(&app, &config);
4083
4084 assert_eq!(view.resolved_model(), "auto");
4085 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
4086 }
4087
4088 #[test]
4089 fn picker_auto_model_preserves_explicit_effort_on_apply() {
4090 let (mut app, config, _lock) = create_test_app();
4091 app.model = "auto".to_string();
4092 app.auto_model = true;
4093 app.reasoning_effort = ReasoningEffort::Low;
4094 app.reasoning_effort_preference = Some(ReasoningEffort::Low);
4095
4096 let view = ModelPickerView::new(&app, &config);
4097
4098 assert_eq!(view.resolved_model(), "auto");
4099 assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
4100 assert_eq!(view.current_efforts(), AUTO_MODEL_PICKER_EFFORTS);
4101 }
4102
4103 #[test]
4104 fn picker_model_navigation_preserves_raw_effort_request() {
4105 let (mut app, config, _lock) = create_test_app();
4106 app.api_provider = crate::config::ApiProvider::Deepseek;
4107 app.model = "deepseek-v4-pro".to_string();
4108 app.auto_model = false;
4109 app.reasoning_effort = ReasoningEffort::High;
4110 app.reasoning_effort_preference = Some(ReasoningEffort::Low);
4111
4112 let mut view = ModelPickerView::new(&app, &config);
4113 assert_eq!(view.initial_effort, ReasoningEffort::Low);
4114 assert_eq!(
4115 view.resolved_effort(),
4116 ReasoningEffort::Low,
4117 "first-party DeepSeek routes carry low as a real wire tier"
4118 );
4119
4120 view.selected_model_idx = view
4121 .visible_model_rows()
4122 .iter()
4123 .position(|row| row.id == "auto")
4124 .expect("Auto row");
4125 view.select_effort_for_current_model();
4126
4127 assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
4128 assert!(matches!(
4129 view.build_event(),
4130 ViewEvent::ModelPickerApplied {
4131 effort: ReasoningEffort::Low,
4132 previous_effort: ReasoningEffort::Low,
4133 ..
4134 }
4135 ));
4136 }
4137
4138 #[test]
4139 fn picker_auto_row_commits_visible_implicit_effort() {
4140 let (mut app, config, _lock) = create_test_app();
4141 app.api_provider = crate::config::ApiProvider::Deepseek;
4142 app.model = "deepseek-v4-pro".to_string();
4143 app.auto_model = false;
4144 app.reasoning_effort = ReasoningEffort::High;
4145 app.reasoning_effort_preference = None;
4146
4147 let mut view = ModelPickerView::new(&app, &config);
4148 assert_eq!(view.initial_effort, ReasoningEffort::Auto);
4149 view.selected_model_idx = view
4150 .visible_model_rows()
4151 .iter()
4152 .position(|row| row.id == "auto")
4153 .expect("Auto row");
4154 view.select_effort_for_current_model();
4155
4156 assert_eq!(view.resolved_effort(), ReasoningEffort::High);
4157 assert!(matches!(
4158 view.build_event(),
4159 ViewEvent::ModelPickerApplied {
4160 effort: ReasoningEffort::High,
4161 previous_effort: ReasoningEffort::Auto,
4162 ..
4163 }
4164 ));
4165 }
4166
4167 #[test]
4168 fn picker_normalizes_low_medium_to_high() {
4169 let (mut app, config, _lock) = create_test_app();
4170 app.reasoning_effort = ReasoningEffort::Medium;
4171 app.auto_model = false;
4172 let view = ModelPickerView::new(&app, &config);
4173 assert_eq!(
4174 view.resolved_effort(),
4175 ReasoningEffort::High,
4176 "medium should map to high in the picker"
4177 );
4178 }
4179
4180 #[test]
4181 fn picker_preserves_kimi_code_k3_low_medium_but_not_generic_moonshot() {
4182 let (mut app, mut config, _lock) = create_test_app();
4183 config.provider = Some("moonshot".to_string());
4184 config.providers = Some(crate::config::ProvidersConfig {
4185 moonshot: crate::config::ProviderConfig {
4186 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
4187 model: Some("k3".to_string()),
4188 ..Default::default()
4189 },
4190 ..Default::default()
4191 });
4192 app.api_provider = crate::config::ApiProvider::Moonshot;
4193 app.model = "k3".to_string();
4194 app.auto_model = false;
4195 app.reasoning_effort = ReasoningEffort::Medium;
4196
4197 let view = ModelPickerView::new(&app, &config);
4198 assert_eq!(view.resolved_effort(), ReasoningEffort::Medium);
4199 assert_eq!(
4200 view.current_efforts(),
4201 KIMI_CODE_K3_PICKER_EFFORTS.to_vec(),
4202 "the official K3 route must expose low/medium before sending a secret"
4203 );
4204
4205 config
4206 .providers
4207 .as_mut()
4208 .expect("providers")
4209 .moonshot
4210 .base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
4211 let generic = ModelPickerView::new(&app, &config);
4212 assert_eq!(generic.resolved_effort(), ReasoningEffort::High);
4213 assert_eq!(generic.current_efforts(), DEFAULT_PICKER_EFFORTS.to_vec());
4214 }
4215
4216 #[test]
4217 fn picker_exposes_auto_and_distinct_thinking_tiers() {
4218 let model_labels = picker_model_ids_for_provider(crate::config::ApiProvider::Deepseek);
4219 assert_eq!(
4220 model_labels,
4221 vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"]
4222 );
4223
4224 let effort_labels: Vec<_> = picker_efforts_for_route(
4225 crate::config::ApiProvider::Deepseek,
4226 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
4227 "deepseek-v4-pro",
4228 false,
4229 )
4230 .iter()
4231 .map(|effort| effort.as_setting())
4232 .collect();
4233 // First-party DeepSeek documents a real `low` wire tier (#52), so the
4234 // picker exposes it; medium stays hidden because the wire has none.
4235 assert_eq!(effort_labels, vec!["auto", "off", "low", "high", "max"]);
4236 }
4237
4238 #[test]
4239 fn codex_picker_exposes_responses_reasoning_tiers() {
4240 let (mut app, config, _lock) = create_test_app();
4241 app.api_provider = crate::config::ApiProvider::OpenaiCodex;
4242 app.model = "gpt-5.5-codex".to_string();
4243 app.auto_model = false;
4244 app.reasoning_effort = ReasoningEffort::Off;
4245
4246 let view = ModelPickerView::new(&app, &config);
4247
4248 assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
4249 let labels: Vec<_> = picker_efforts_for_route(
4250 crate::config::ApiProvider::OpenaiCodex,
4251 crate::config::DEFAULT_OPENAI_CODEX_BASE_URL,
4252 "gpt-5.5-codex",
4253 false,
4254 )
4255 .iter()
4256 .map(|effort| effort.display_label_for_provider(crate::config::ApiProvider::OpenaiCodex))
4257 .collect();
4258 assert_eq!(labels, vec!["low", "medium", "high", "xhigh"]);
4259 }
4260
4261 #[test]
4262 fn picker_includes_saved_codex_model_as_a_provider_owned_choice() {
4263 let (mut app, config, _lock) = create_test_app();
4264 app.api_provider = crate::config::ApiProvider::Deepseek;
4265 app.model = "deepseek-v4-pro".to_string();
4266 app.auto_model = false;
4267 app.reasoning_effort = ReasoningEffort::Off;
4268 app.provider_models
4269 .insert("openai-codex".to_string(), "gpt-5.5".to_string());
4270
4271 let view = ModelPickerView::new(&app, &config);
4272 assert_eq!(view.resolved_effort(), ReasoningEffort::Off);
4273 assert!(view.visible_model_rows().iter().any(|row| {
4274 row.provider == Some(crate::config::ApiProvider::OpenaiCodex) && row.id == "gpt-5.5"
4275 }));
4276 }
4277
4278 #[test]
4279 fn picker_navigation_previews_cross_provider_without_mutating_session() {
4280 let (mut app, config, _lock) = create_test_app();
4281 app.api_provider = crate::config::ApiProvider::Deepseek;
4282 app.model = "deepseek-v4-pro".to_string();
4283 app.auto_model = false;
4284 app.reasoning_effort = ReasoningEffort::Max;
4285 app.provider_models
4286 .insert("openai-codex".to_string(), "gpt-5.5".to_string());
4287
4288 let mut view = ModelPickerView::new(&app, &config);
4289 let mut saw_codex = false;
4290 while view.move_down() {
4291 saw_codex |= view.resolved_provider() == Some(crate::config::ApiProvider::OpenaiCodex);
4292 }
4293
4294 assert!(saw_codex, "saved cross-provider choice remains navigable");
4295 assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek);
4296 assert_eq!(view.initial_provider, crate::config::ApiProvider::Deepseek);
4297 }
4298
4299 #[test]
4300 fn picker_query_reveals_cross_provider_route_rows() {
4301 let (mut app, config, _lock) = create_test_app();
4302 app.api_provider = crate::config::ApiProvider::Deepseek;
4303 app.model = "deepseek-v4-pro".to_string();
4304 app.auto_model = false;
4305
4306 let mut view = ModelPickerView::new(&app, &config);
4307 assert!(
4308 view.visible_model_rows()
4309 .iter()
4310 .all(|row| row.provider.is_none()
4311 || row.provider == Some(crate::config::ApiProvider::Deepseek))
4312 );
4313
4314 type_model_query(&mut view, "openrouter");
4315
4316 assert!(
4317 view.visible_model_rows()
4318 .iter()
4319 .any(|row| row.provider == Some(crate::config::ApiProvider::Openrouter)),
4320 "query should reveal explicit OpenRouter route rows"
4321 );
4322 assert_eq!(
4323 view.resolved_provider(),
4324 Some(crate::config::ApiProvider::Openrouter)
4325 );
4326 }
4327
4328 #[test]
4329 fn picker_query_cross_provider_enter_emits_provider_switch() {
4330 let (mut app, mut config, _lock) = create_test_app();
4331 app.api_provider = crate::config::ApiProvider::Deepseek;
4332 app.model = "deepseek-v4-pro".to_string();
4333 app.auto_model = false;
4334 config.providers = Some(crate::config::ProvidersConfig {
4335 openrouter: crate::config::ProviderConfig {
4336 api_key: Some("openrouter-picker-test-key".to_string()),
4337 ..Default::default()
4338 },
4339 ..Default::default()
4340 });
4341
4342 let mut view = ModelPickerView::new(&app, &config);
4343 type_model_query(&mut view, "openrouter");
4344
4345 let action = view.handle_key(KeyEvent::new(
4346 KeyCode::Enter,
4347 crossterm::event::KeyModifiers::NONE,
4348 ));
4349 match action {
4350 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
4351 model, provider, ..
4352 }) => {
4353 assert_eq!(provider, Some(crate::config::ApiProvider::Openrouter));
4354 assert!(
4355 !model.trim().is_empty() && model != "auto",
4356 "cross-provider row must carry a concrete wire model"
4357 );
4358 }
4359 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
4360 }
4361 }
4362
4363 #[test]
4364 fn picker_query_no_match_custom_row_stays_active_provider_scoped() {
4365 let (mut app, mut config, _lock) = create_test_app();
4366 app.api_provider = crate::config::ApiProvider::Openrouter;
4367 app.model_ids_passthrough = true;
4368 app.model = crate::config::DEFAULT_OPENROUTER_MODEL.to_string();
4369 app.auto_model = false;
4370 config.provider = Some("openrouter".to_string());
4371 config.providers = Some(crate::config::ProvidersConfig {
4372 openrouter: crate::config::ProviderConfig {
4373 api_key: Some("openrouter-picker-test-key".to_string()),
4374 ..Default::default()
4375 },
4376 ..Default::default()
4377 });
4378
4379 let mut view = ModelPickerView::new(&app, &config);
4380 type_model_query(&mut view, "custom-org/custom-model");
4381
4382 assert_eq!(view.resolved_model(), "custom-org/custom-model");
4383 assert_eq!(
4384 view.resolved_provider(),
4385 Some(crate::config::ApiProvider::Openrouter)
4386 );
4387 let action = view.handle_key(KeyEvent::new(
4388 KeyCode::Enter,
4389 crossterm::event::KeyModifiers::NONE,
4390 ));
4391 match action {
4392 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
4393 model, provider, ..
4394 }) => {
4395 assert_eq!(model, "custom-org/custom-model");
4396 assert_eq!(provider, None, "active-provider custom row is not a switch");
4397 }
4398 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
4399 }
4400 }
4401
4402 #[test]
4403 fn picker_query_provider_qualified_custom_row_targets_configured_provider() {
4404 let (mut app, _default_config, _lock) = create_test_app();
4405 app.api_provider = crate::config::ApiProvider::Deepseek;
4406 app.model_ids_passthrough = false;
4407 app.model = "deepseek-v4-pro".to_string();
4408 app.auto_model = false;
4409 let config = Config {
4410 providers: Some(crate::config::ProvidersConfig {
4411 openrouter: crate::config::ProviderConfig {
4412 api_key: Some("test-openrouter-key".to_string()),
4413 ..Default::default()
4414 },
4415 ..Default::default()
4416 }),
4417 ..Config::default()
4418 };
4419
4420 let mut view = ModelPickerView::new(&app, &config);
4421 type_model_query(&mut view, "openrouter:anthropic/custom-sonnet");
4422
4423 assert_eq!(view.resolved_model(), "anthropic/custom-sonnet");
4424 assert_eq!(
4425 view.resolved_provider(),
4426 Some(crate::config::ApiProvider::Openrouter)
4427 );
4428 let action = view.handle_key(KeyEvent::new(
4429 KeyCode::Enter,
4430 crossterm::event::KeyModifiers::NONE,
4431 ));
4432 match action {
4433 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
4434 model, provider, ..
4435 }) => {
4436 assert_eq!(model, "anthropic/custom-sonnet");
4437 assert_eq!(provider, Some(crate::config::ApiProvider::Openrouter));
4438 }
4439 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
4440 }
4441 }
4442
4443 #[test]
4444 fn picker_query_no_match_strict_provider_enter_is_noop() {
4445 let (mut app, config, _lock) = create_test_app();
4446 app.api_provider = crate::config::ApiProvider::Deepseek;
4447 app.model_ids_passthrough = false;
4448 app.model = "deepseek-v4-pro".to_string();
4449 app.auto_model = false;
4450
4451 let mut view = ModelPickerView::new(&app, &config);
4452 type_model_query(&mut view, "definitely-not-a-deepseek-model");
4453
4454 assert_eq!(view.model_row_count(), 0);
4455 let action = view.handle_key(KeyEvent::new(
4456 KeyCode::Enter,
4457 crossterm::event::KeyModifiers::NONE,
4458 ));
4459 assert!(matches!(action, ViewAction::None));
4460 }
4461
4462 #[test]
4463 fn picker_query_backspace_restores_active_provider_rows() {
4464 let (mut app, config, _lock) = create_test_app();
4465 app.api_provider = crate::config::ApiProvider::Deepseek;
4466 app.model = "deepseek-v4-pro".to_string();
4467 app.auto_model = false;
4468
4469 let mut view = ModelPickerView::new(&app, &config);
4470 type_model_query(&mut view, "openrouter");
4471 assert!(
4472 view.visible_model_rows()
4473 .iter()
4474 .any(|row| row.provider == Some(crate::config::ApiProvider::Openrouter))
4475 );
4476
4477 for _ in 0.."openrouter".len() {
4478 view.handle_key(KeyEvent::new(
4479 KeyCode::Backspace,
4480 crossterm::event::KeyModifiers::NONE,
4481 ));
4482 }
4483
4484 assert!(view.query.is_empty());
4485 assert!(
4486 view.visible_model_rows()
4487 .iter()
4488 .all(|row| row.provider.is_none()
4489 || row.provider == Some(crate::config::ApiProvider::Deepseek))
4490 );
4491 }
4492
4493 #[test]
4494 fn picker_effort_pane_ignores_query_typing() {
4495 let (app, config, _lock) = create_test_app();
4496 let mut view = ModelPickerView::new(&app, &config);
4497 view.handle_key(KeyEvent::new(
4498 KeyCode::Tab,
4499 crossterm::event::KeyModifiers::NONE,
4500 ));
4501
4502 type_model_query(&mut view, "openrouter");
4503
4504 assert_eq!(view.focus, Pane::Effort);
4505 assert!(view.query.is_empty());
4506 assert!(
4507 view.visible_model_rows()
4508 .iter()
4509 .all(|row| row.provider.is_none()
4510 || row.provider == Some(crate::config::ApiProvider::Deepseek))
4511 );
4512 }
4513
4514 #[test]
4515 fn picker_query_resyncs_effort_for_codex_rows() {
4516 let (mut app, config, _lock) = create_test_app();
4517 app.api_provider = crate::config::ApiProvider::Deepseek;
4518 app.model = "deepseek-v4-pro".to_string();
4519 app.auto_model = false;
4520 app.reasoning_effort = ReasoningEffort::Auto;
4521
4522 let mut view = ModelPickerView::new(&app, &config);
4523 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
4524
4525 type_model_query(&mut view, "codex");
4526
4527 assert_eq!(
4528 view.resolved_provider(),
4529 Some(crate::config::ApiProvider::OpenaiCodex)
4530 );
4531 assert_eq!(
4532 view.resolved_effort(),
4533 ReasoningEffort::Medium,
4534 "OpenAI Codex rows should normalize auto to medium"
4535 );
4536 }
4537
4538 /// #4639 — typed search ranks provider-matching rows first (drill-down),
4539 /// then exact/prefix id matches, so provider-heavy catalogs surface the
4540 /// intended route in the first rows instead of raw catalog order.
4541 #[test]
4542 fn picker_query_ranks_provider_matches_before_id_substrings() {
4543 let (mut app, config, _lock) = create_test_app();
4544 app.api_provider = crate::config::ApiProvider::Deepseek;
4545 app.model = "deepseek-v4-pro".to_string();
4546 app.auto_model = false;
4547
4548 let mut view = ModelPickerView::new(&app, &config);
4549
4550 // A provider-name query lands on that provider's rows first.
4551 type_model_query(&mut view, "zai");
4552 let rows = view.visible_model_rows();
4553 let first = rows.first().expect("zai query should surface rows");
4554 assert_eq!(first.provider, Some(ApiProvider::Zai));
4555
4556 // Prefix matches outrank substrings even when alphabetical order
4557 // disagrees: "deepseek-v4-p" prefix-matches pro but only
4558 // substring-matches flash, so pro leads although flash < pro.
4559 view.update_query(String::new());
4560 type_model_query(&mut view, "deepseek-v4-p");
4561 let ids: Vec<&str> = view
4562 .visible_model_rows()
4563 .iter()
4564 .map(|row| row.id.as_str())
4565 .collect();
4566 assert!(!ids.is_empty(), "deepseek-v4-p query should match rows");
4567 assert_eq!(
4568 ids.first().copied(),
4569 Some("deepseek-v4-pro"),
4570 "prefix match must outrank substring match: {ids:?}"
4571 );
4572 }
4573
4574 /// A cross-provider row used by the #4141 cross-field search tests: an
4575 /// active DeepSeek session browsing Z.ai's `z-ai/glm-5.2` route.
4576 fn cross_provider_row() -> ModelPickerRow {
4577 ModelPickerRow {
4578 id: "z-ai/glm-5.2".to_string(),
4579 provider: Some(ApiProvider::Zai),
4580 provider_identity: None,
4581 hint: "switch route · reasoning".to_string(),
4582 metadata: EffectivePickerMetadata::default(),
4583 selectable: true,
4584 blocked_reason: None,
4585 enabled: true,
4586 }
4587 }
4588
4589 #[test]
4590 fn model_row_query_matches_provider_name() {
4591 let row = cross_provider_row();
4592 // Provider key (`zai`) and human display name (`Zhipu AI / Z.ai`) both
4593 // match, even though neither is a substring of the wire model id.
4594 assert!(model_row_matches_query(&row, "zai", ApiProvider::Deepseek));
4595 assert!(model_row_matches_query(
4596 &row,
4597 "zhipu",
4598 ApiProvider::Deepseek
4599 ));
4600 // Case-insensitive, matching the provider picker.
4601 assert!(model_row_matches_query(
4602 &row,
4603 "ZHIPU",
4604 ApiProvider::Deepseek
4605 ));
4606 }
4607
4608 #[test]
4609 fn model_row_query_matches_display_model_name() {
4610 let row = cross_provider_row();
4611 // `row.id` is what the picker renders as the model's display name.
4612 assert!(model_row_matches_query(
4613 &row,
4614 "glm-5.2",
4615 ApiProvider::Deepseek
4616 ));
4617 assert!(model_row_matches_query(&row, "GLM", ApiProvider::Deepseek));
4618 }
4619
4620 #[test]
4621 fn model_row_query_matches_wire_model_id() {
4622 let row = cross_provider_row();
4623 // The full wire id (as sent to the provider) is searchable too, mirroring
4624 // the provider picker's route wire-model match (#4141).
4625 assert!(model_row_matches_query(
4626 &row,
4627 "z-ai/glm-5.2",
4628 ApiProvider::Deepseek
4629 ));
4630 assert!(model_row_matches_query(
4631 &row,
4632 "z-ai/",
4633 ApiProvider::Deepseek
4634 ));
4635 }
4636
4637 #[test]
4638 fn model_row_query_treats_hyphens_like_human_word_breaks() {
4639 let row = ModelPickerRow {
4640 id: "deepseek-v4-flash".to_string(),
4641 provider: Some(ApiProvider::Deepseek),
4642 provider_identity: None,
4643 hint: String::new(),
4644 metadata: EffectivePickerMetadata::default(),
4645 selectable: true,
4646 blocked_reason: None,
4647 enabled: true,
4648 };
4649 assert!(model_row_matches_query(
4650 &row,
4651 "deepseek v4 flash",
4652 ApiProvider::Meta
4653 ));
4654 }
4655
4656 #[test]
4657 fn model_row_query_no_field_match_returns_false() {
4658 let row = cross_provider_row();
4659 // `openai` is in neither the provider name/key, the display model name,
4660 // nor the wire id, and the hint is not searched for cross-provider rows,
4661 // so the row must not match.
4662 assert!(!model_row_matches_query(
4663 &row,
4664 "openai",
4665 ApiProvider::Deepseek
4666 ));
4667 }
4668
4669 #[test]
4670 fn picker_query_by_wire_id_surfaces_cross_provider_row_and_hides_others() {
4671 let (mut app, config, _lock) = create_test_app();
4672 app.api_provider = crate::config::ApiProvider::Deepseek;
4673 app.model = "deepseek-v4-pro".to_string();
4674 app.auto_model = false;
4675
4676 let mut view = ModelPickerView::new(&app, &config);
4677 // A GLM model id belongs to Z.ai; searching it surfaces that route while
4678 // a query that matches no provider/model/wire field yields no rows.
4679 type_model_query(&mut view, "glm");
4680 assert!(
4681 view.visible_model_rows()
4682 .iter()
4683 .any(|row| row.provider == Some(crate::config::ApiProvider::Zai)),
4684 "searching a model name must surface the provider that serves it"
4685 );
4686
4687 view.update_query(String::new());
4688 type_model_query(&mut view, "zzz-no-such-provider-or-model");
4689 assert!(
4690 view.visible_model_rows().is_empty(),
4691 "a query matching no provider/model/wire field must return no rows"
4692 );
4693 }
4694
4695 #[test]
4696 fn picker_preserves_unknown_model_via_custom_row() {
4697 let (mut app, config, _lock) = create_test_app();
4698 app.model = "deepseek-v4-pro-2026-04-XX".to_string();
4699 app.auto_model = false;
4700 let view = ModelPickerView::new(&app, &config);
4701 assert!(view.show_custom_model_row);
4702 assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
4703 }
4704
4705 #[test]
4706 fn picker_lists_openrouter_catalog_models() {
4707 let (mut app, config, _lock) = create_test_app();
4708 app.api_provider = crate::config::ApiProvider::Openrouter;
4709 app.model_ids_passthrough = true;
4710 app.model = "minimax/minimax-m3".to_string();
4711 app.auto_model = false;
4712
4713 let view = ModelPickerView::new(&app, &config);
4714 let model_ids: Vec<_> = view
4715 .model_rows
4716 .iter()
4717 .filter(|row| row.provider == Some(crate::config::ApiProvider::Openrouter))
4718 .map(|row| row.id.as_str())
4719 .collect();
4720
4721 for expected in [
4722 "deepseek/deepseek-v4-pro",
4723 "deepseek/deepseek-v4-flash",
4724 "qwen/qwen3.6-flash",
4725 "qwen/qwen3.7-plus",
4726 "minimax/minimax-m3",
4727 ] {
4728 assert!(
4729 model_ids.contains(&expected),
4730 "missing {expected}: {model_ids:?}"
4731 );
4732 }
4733 assert!(!view.show_custom_model_row);
4734 assert_eq!(view.resolved_model(), "minimax/minimax-m3");
4735 }
4736
4737 #[test]
4738 fn v090_picker_metadata_preserves_unknown_catalog_limits_and_prices() {
4739 let config = Config::default();
4740
4741 let qwen =
4742 effective_picker_metadata(&config, Some(ApiProvider::Openrouter), "qwen/qwen3.7-plus");
4743 assert_eq!(qwen.context_window, None);
4744 assert_eq!(qwen.max_output, None);
4745 assert!(qwen.reasoning);
4746 assert!(matches!(qwen.pricing, PickerPricing::Known(_)));
4747
4748 let trinity = effective_picker_metadata(&config, Some(ApiProvider::Arcee), "trinity-mini");
4749 assert_eq!(trinity.context_window, Some(128_000));
4750 assert_eq!(trinity.max_output, None);
4751 assert!(trinity.reasoning);
4752 assert_eq!(trinity.pricing, PickerPricing::Unknown);
4753
4754 let inkling = effective_picker_metadata(
4755 &config,
4756 Some(ApiProvider::Together),
4757 crate::config::TOGETHER_INKLING_MODEL,
4758 );
4759 assert_eq!(inkling.context_window, None);
4760 assert_eq!(inkling.max_output, None);
4761 assert!(inkling.reasoning);
4762 assert_eq!(inkling.pricing, PickerPricing::Unknown);
4763 }
4764
4765 #[test]
4766 fn picker_lists_xiaomi_mimo_chat_models_without_speech_models() {
4767 let (mut app, config, _lock) = create_test_app();
4768 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
4769 app.model = "mimo-v2.5-pro".to_string();
4770 app.auto_model = false;
4771
4772 let view = ModelPickerView::new(&app, &config);
4773 let model_ids: Vec<_> = view
4774 .model_rows
4775 .iter()
4776 .filter(|row| row.provider == Some(crate::config::ApiProvider::XiaomiMimo))
4777 .map(|row| row.id.as_str())
4778 .collect();
4779
4780 for expected in ["mimo-v2.5-pro", "mimo-v2.5"] {
4781 assert!(model_ids.contains(&expected), "missing {expected}");
4782 }
4783 for deprecated in ["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"] {
4784 assert!(
4785 !model_ids.contains(&deprecated),
4786 "{deprecated} is deprecated and should not be promoted"
4787 );
4788 }
4789 for speech_model in [
4790 "mimo-v2.5-tts",
4791 "mimo-v2.5-tts-voicedesign",
4792 "mimo-v2.5-tts-voiceclone",
4793 "mimo-v2-tts",
4794 ] {
4795 assert!(
4796 !model_ids.contains(&speech_model),
4797 "{speech_model} should not appear in the chat model picker"
4798 );
4799 }
4800 }
4801
4802 #[test]
4803 fn picker_lists_current_opencode_go_chat_models_only() {
4804 let (mut app, config, _lock) = create_test_app();
4805 app.api_provider = crate::config::ApiProvider::OpencodeGo;
4806 app.model = crate::config::DEFAULT_OPENCODE_GO_MODEL.to_string();
4807 app.auto_model = false;
4808
4809 let view = ModelPickerView::new(&app, &config);
4810 let model_ids: Vec<_> = view
4811 .model_rows
4812 .iter()
4813 .filter(|row| row.provider == Some(crate::config::ApiProvider::OpencodeGo))
4814 .map(|row| row.id.as_str())
4815 .collect();
4816
4817 for expected in ["grok-4.5", "kimi-k3"] {
4818 assert!(
4819 model_ids.contains(&expected),
4820 "missing {expected}: {model_ids:?}"
4821 );
4822 }
4823 for messages_only in ["minimax-m3", "qwen3.7-max"] {
4824 assert!(
4825 !model_ids.contains(&messages_only),
4826 "{messages_only} must remain excluded from the Chat-only picker"
4827 );
4828 }
4829 }
4830
4831 #[test]
4832 fn picker_for_ollama_preserves_current_local_tag_without_hosted_static_rows() {
4833 let (mut app, config, _lock) = create_test_app();
4834 app.api_provider = crate::config::ApiProvider::Ollama;
4835 app.model_ids_passthrough = true;
4836 app.model = "qwen2.5-coder:7b".to_string();
4837 app.auto_model = false;
4838
4839 let view = ModelPickerView::new(&app, &config);
4840 let model_ids = view.visible_model_ids();
4841
4842 assert_eq!(model_ids, vec!["auto"]);
4843 assert!(view.show_custom_model_row);
4844 assert_eq!(view.resolved_model(), "qwen2.5-coder:7b");
4845 }
4846
4847 #[test]
4848 fn visible_row_window_tracks_selection_in_short_panes() {
4849 assert_eq!(visible_row_window(0, 16, 8), (0, 8));
4850 assert_eq!(visible_row_window(7, 16, 8), (3, 11));
4851 assert_eq!(visible_row_window(15, 16, 8), (8, 16));
4852 assert_eq!(visible_row_window(3, 4, 8), (0, 4));
4853 assert_eq!(visible_row_window(3, 4, 0), (0, 0));
4854 }
4855
4856 #[test]
4857 fn narrow_picker_rows_hide_hint_before_clipping_model_id() {
4858 let row = PaneRow::effort(
4859 "minimax/minimax-m3".to_string(),
4860 "1M multimodal".to_string(),
4861 );
4862 let spans = picker_row_spans(
4863 &row,
4864 "▸",
4865 24,
4866 ModelRowColumns::for_page(std::slice::from_ref(&row)),
4867 Style::default(),
4868 Style::default(),
4869 );
4870 let rendered = spans
4871 .iter()
4872 .map(|span| span.content.as_ref())
4873 .collect::<String>();
4874
4875 assert!(rendered.contains("minimax/minimax-m3"));
4876 assert!(!rendered.contains("1M multimodal"));
4877 assert!(unicode_width::UnicodeWidthStr::width(rendered.as_str()) <= 24);
4878 }
4879
4880 #[test]
4881 fn picker_preserves_custom_passthrough_model_ids() {
4882 let (mut app, config, _lock) = create_test_app();
4883 app.api_provider = crate::config::ApiProvider::Openrouter;
4884 app.model_ids_passthrough = true;
4885 app.model = "opencode-go/glm-5.1".to_string();
4886 app.auto_model = false;
4887
4888 let view = ModelPickerView::new(&app, &config);
4889
4890 assert!(view.show_custom_model_row);
4891 assert_eq!(view.resolved_model(), "opencode-go/glm-5.1");
4892 }
4893
4894 #[test]
4895 fn picker_exposes_active_custom_provider_model_row() {
4896 let (mut app, config, _lock) = create_test_app();
4897 app.api_provider = crate::config::ApiProvider::Custom;
4898 app.model_ids_passthrough = true;
4899 app.model = "vendor/custom-model-v1".to_string();
4900 app.auto_model = false;
4901
4902 let view = ModelPickerView::new(&app, &config);
4903
4904 assert!(view.show_custom_model_row);
4905 assert_eq!(view.resolved_model(), "vendor/custom-model-v1");
4906 assert_eq!(
4907 view.resolved_provider(),
4908 Some(crate::config::ApiProvider::Custom)
4909 );
4910 }
4911
4912 #[test]
4913 fn named_custom_picker_event_keeps_exact_target_identity() {
4914 let (mut app, mut config, _lock) = create_test_app();
4915 app.set_provider_identity(crate::config::ApiProvider::Custom, "custom-a");
4916 app.model_ids_passthrough = true;
4917 app.model = "model-a".to_string();
4918 app.auto_model = false;
4919 let mut custom = std::collections::HashMap::new();
4920 for (name, base_url, model) in [
4921 ("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
4922 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
4923 ] {
4924 custom.insert(
4925 name.to_string(),
4926 crate::config::ProviderConfig {
4927 kind: Some("openai-compatible".to_string()),
4928 base_url: Some(base_url.to_string()),
4929 model: Some(model.to_string()),
4930 api_key: Some("local-test-key".to_string()),
4931 ..Default::default()
4932 },
4933 );
4934 }
4935 config.provider = Some("custom-b".to_string());
4936 config.providers = Some(crate::config::ProvidersConfig {
4937 custom,
4938 ..Default::default()
4939 });
4940 let mut view = ModelPickerView::new(&app, &config);
4941 view.selected_model_idx = view
4942 .visible_model_rows()
4943 .iter()
4944 .position(|row| row.provider == Some(ApiProvider::Custom) && row.id == "model-b")
4945 .expect("custom B row");
4946
4947 app.pinned_models = vec![PinnedModel {
4948 provider: "custom-a".to_string(),
4949 model: "model-b".to_string(),
4950 label: Some("A's pin".to_string()),
4951 }];
4952 app.set_provider_identity(crate::config::ApiProvider::Custom, "custom-b");
4953 let rows = picker_model_rows_for_app(&app, &config);
4954 assert!(rows.iter().any(|row| {
4955 row.provider == Some(ApiProvider::Custom)
4956 && row.provider_identity.as_deref() == Some("custom-b")
4957 && row.id == "model-b"
4958 }));
4959 assert!(rows.iter().any(|row| {
4960 row.provider == Some(ApiProvider::Custom)
4961 && row.provider_identity.as_deref() == Some("custom-a")
4962 && row.id == "model-b"
4963 && row.hint.contains("stale pinned")
4964 }));
4965
4966 match view.build_event() {
4967 ViewEvent::ModelPickerApplied {
4968 model,
4969 provider,
4970 provider_id,
4971 ..
4972 } => {
4973 assert_eq!(model, "model-b");
4974 assert_eq!(provider, None, "both routes share the Custom enum");
4975 assert_eq!(provider_id.as_deref(), Some("custom-b"));
4976 }
4977 other => panic!("expected model picker apply event, got {other:?}"),
4978 }
4979 }
4980
4981 #[test]
4982 fn picker_exposes_saved_model_for_active_provider() {
4983 let (mut app, mut config, _lock) = create_test_app();
4984 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
4985 app.model = "mimo-v2.5-custom".to_string();
4986 app.auto_model = false;
4987 app.provider_models
4988 .insert("xiaomi-mimo".to_string(), "mimo-v2.5-custom".to_string());
4989 config.provider = Some("xiaomi-mimo".to_string());
4990 config.providers = Some(crate::config::ProvidersConfig {
4991 xiaomi_mimo: crate::config::ProviderConfig {
4992 api_key: Some("mimo-picker-test-key".to_string()),
4993 ..Default::default()
4994 },
4995 ..Default::default()
4996 });
4997
4998 let mut view = ModelPickerView::new(&app, &config);
4999 view.selected_model_idx = view
5000 .visible_model_rows()
5001 .iter()
5002 .position(|row| {
5003 row.id == "mimo-v2.5-custom"
5004 && row.provider == Some(crate::config::ApiProvider::XiaomiMimo)
5005 })
5006 .expect("saved Xiaomi MiMo model row");
5007
5008 let action = view.handle_key(KeyEvent::new(
5009 KeyCode::Enter,
5010 crossterm::event::KeyModifiers::NONE,
5011 ));
5012 match action {
5013 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
5014 model, provider, ..
5015 }) => {
5016 assert_eq!(model, "mimo-v2.5-custom");
5017 assert_eq!(provider, None);
5018 }
5019 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
5020 }
5021 }
5022
5023 #[test]
5024 fn picker_migrates_saved_models_from_other_providers() {
5025 let (mut app, config, _lock) = create_test_app();
5026 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
5027 app.model = "mimo-v2.5-pro".to_string();
5028 app.auto_model = false;
5029 app.provider_models
5030 .insert("deepseek".to_string(), "deepseek-v4-pro".to_string());
5031 app.provider_models
5032 .insert("moonshot".to_string(), "kimi-k2.6".to_string());
5033 app.provider_models
5034 .insert("openai".to_string(), "qwen-plus".to_string());
5035 app.provider_models.insert(
5036 "qianfan".to_string(),
5037 "custom-qianfan-service-id".to_string(),
5038 );
5039
5040 let view = ModelPickerView::new(&app, &config);
5041 let model_ids = view.visible_model_ids();
5042
5043 // Active provider's own model stays present (and ahead of the tail).
5044 assert!(model_ids.contains(&"mimo-v2.5-pro"));
5045 // Existing provider-specific preferences are already user-owned choices
5046 // and migrate into the conservative list without enabling full catalogs.
5047 assert!(model_ids.contains(&"deepseek-v4-pro"));
5048 assert!(model_ids.contains(&"kimi-k2.6"));
5049 assert!(model_ids.contains(&"qwen-plus"));
5050 assert!(model_ids.contains(&"custom-qianfan-service-id"));
5051 assert!(!view.show_custom_model_row);
5052 assert!(view.visible_model_rows().iter().all(|row| {
5053 row.provider.is_none()
5054 || row.provider == Some(crate::config::ApiProvider::XiaomiMimo)
5055 || app
5056 .provider_models
5057 .contains_key(row.provider.unwrap().as_str())
5058 }));
5059 }
5060
5061 #[test]
5062 fn picker_skips_unknown_provider_saved_models() {
5063 // A config key that maps to no known provider cannot be applied, so it
5064 // must not produce a picker row (#2596).
5065 let (mut app, config, _lock) = create_test_app();
5066 app.api_provider = crate::config::ApiProvider::XiaomiMimo;
5067 app.model = "mimo-v2.5-pro".to_string();
5068 app.auto_model = false;
5069 app.provider_models
5070 .insert("totally-unknown".to_string(), "ghost-model".to_string());
5071
5072 let view = ModelPickerView::new(&app, &config);
5073 assert!(!view.visible_model_ids().contains(&"ghost-model"));
5074 }
5075
5076 #[test]
5077 fn picker_does_not_hijack_current_custom_model_with_saved_provider_row() {
5078 let (mut app, mut config, _lock) = create_test_app();
5079 app.api_provider = crate::config::ApiProvider::Openai;
5080 app.model_ids_passthrough = true;
5081 app.model = "kimi-k2.6".to_string();
5082 app.provider_models
5083 .insert("moonshot".to_string(), "kimi-k2.6".to_string());
5084 config.provider = Some("openai".to_string());
5085 config.providers = Some(crate::config::ProvidersConfig {
5086 openai: crate::config::ProviderConfig {
5087 api_key: Some("openai-picker-test-key".to_string()),
5088 ..Default::default()
5089 },
5090 ..Default::default()
5091 });
5092
5093 let mut view = ModelPickerView::new(&app, &config);
5094
5095 assert!(view.show_custom_model_row);
5096 assert_eq!(view.resolved_model(), "kimi-k2.6");
5097 let action = view.handle_key(KeyEvent::new(
5098 KeyCode::Enter,
5099 crossterm::event::KeyModifiers::NONE,
5100 ));
5101 match action {
5102 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
5103 model, provider, ..
5104 }) => {
5105 assert_eq!(model, "kimi-k2.6");
5106 assert_eq!(provider, None);
5107 }
5108 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
5109 }
5110 }
5111
5112 #[test]
5113 fn arrow_keys_move_within_focused_pane() {
5114 let (mut app, config, _lock) = create_test_app();
5115 app.model = "deepseek-v4-pro".to_string();
5116 app.enable_provider_model("deepseek", "deepseek-v4-flash");
5117 app.reasoning_effort = ReasoningEffort::High;
5118 let mut view = ModelPickerView::new(&app, &config);
5119 assert_eq!(view.selected_model_idx, 1);
5120 view.handle_key(KeyEvent::new(
5121 KeyCode::Down,
5122 crossterm::event::KeyModifiers::NONE,
5123 ));
5124 assert_eq!(view.selected_model_idx, 2);
5125 view.handle_key(KeyEvent::new(
5126 KeyCode::Up,
5127 crossterm::event::KeyModifiers::NONE,
5128 ));
5129 assert_eq!(view.selected_model_idx, 1);
5130
5131 view.handle_key(KeyEvent::new(
5132 KeyCode::Tab,
5133 crossterm::event::KeyModifiers::NONE,
5134 ));
5135 assert_eq!(view.focus, Pane::Effort);
5136 assert_eq!(view.selected_effort_idx, 3);
5137 view.handle_key(KeyEvent::new(
5138 KeyCode::Down,
5139 crossterm::event::KeyModifiers::NONE,
5140 ));
5141 assert_eq!(view.selected_effort_idx, 4);
5142 }
5143
5144 #[test]
5145 fn mouse_wheel_moves_focused_picker_pane() {
5146 let (mut app, config, _lock) = create_test_app();
5147 app.model = "deepseek-v4-pro".to_string();
5148 app.enable_provider_model("deepseek", "deepseek-v4-flash");
5149 let mut view = ModelPickerView::new(&app, &config);
5150 assert_eq!(view.selected_model_idx, 1);
5151
5152 view.handle_mouse(crossterm::event::MouseEvent {
5153 kind: crossterm::event::MouseEventKind::ScrollDown,
5154 column: 0,
5155 row: 0,
5156 modifiers: crossterm::event::KeyModifiers::NONE,
5157 });
5158 assert_eq!(view.selected_model_idx, 2);
5159
5160 view.handle_mouse(crossterm::event::MouseEvent {
5161 kind: crossterm::event::MouseEventKind::ScrollUp,
5162 column: 0,
5163 row: 0,
5164 modifiers: crossterm::event::KeyModifiers::NONE,
5165 });
5166 assert_eq!(view.selected_model_idx, 1);
5167 }
5168
5169 #[test]
5170 fn mouse_click_focuses_row_and_second_click_applies() {
5171 let (app, mut config, _lock) = create_test_app();
5172 config.api_key = Some("deepseek-picker-test-key".to_string());
5173 let mut view = ModelPickerView::new(&app, &config);
5174 let area = Rect::new(0, 0, 100, 30);
5175 let mut buf = Buffer::empty(area);
5176 view.render(area, &mut buf);
5177 let (rect, pane, idx) = view
5178 .row_hitboxes
5179 .borrow()
5180 .iter()
5181 .find(|(_, pane, idx)| *pane == Pane::Effort && *idx == 0)
5182 .copied()
5183 .expect("first effort row should be clickable");
5184 let click = MouseEvent {
5185 kind: MouseEventKind::Down(MouseButton::Left),
5186 column: rect.x,
5187 row: rect.y,
5188 modifiers: crossterm::event::KeyModifiers::NONE,
5189 };
5190
5191 assert!(matches!(view.handle_mouse(click), ViewAction::None));
5192 assert_eq!(view.focus, pane);
5193 assert_eq!(view.selected_effort_idx, idx);
5194 assert!(matches!(
5195 view.handle_mouse(click),
5196 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { .. })
5197 ));
5198 }
5199
5200 #[test]
5201 fn tab_switches_between_model_and_thinking() {
5202 let (app, config, _lock) = create_test_app();
5203 let mut view = ModelPickerView::new(&app, &config);
5204 assert_eq!(view.focus, Pane::Model);
5205 view.handle_key(KeyEvent::new(
5206 KeyCode::Tab,
5207 crossterm::event::KeyModifiers::NONE,
5208 ));
5209 assert_eq!(view.focus, Pane::Effort);
5210 view.handle_key(KeyEvent::new(
5211 KeyCode::BackTab,
5212 crossterm::event::KeyModifiers::SHIFT,
5213 ));
5214 assert_eq!(view.focus, Pane::Model);
5215 }
5216
5217 #[test]
5218 fn enter_emits_current_model_and_thinking() {
5219 let (mut app, mut config, _lock) = create_test_app();
5220 config.api_key = Some("deepseek-picker-test-key".to_string());
5221 app.reasoning_effort = ReasoningEffort::High;
5222 app.reasoning_effort_preference = Some(ReasoningEffort::High);
5223 app.model = "deepseek-v4-pro".to_string();
5224 app.auto_model = false;
5225 app.enable_provider_model("deepseek", "deepseek-v4-flash");
5226 let mut view = ModelPickerView::new(&app, &config);
5227 assert_eq!(view.selected_model_idx, 1);
5228 assert_eq!(view.selected_effort_idx, 3);
5229
5230 // Move model from Pro to Flash, then switch to effort and move High to Max.
5231 view.handle_key(KeyEvent::new(
5232 KeyCode::Down,
5233 crossterm::event::KeyModifiers::NONE,
5234 ));
5235 view.handle_key(KeyEvent::new(
5236 KeyCode::Tab,
5237 crossterm::event::KeyModifiers::NONE,
5238 ));
5239 view.handle_key(KeyEvent::new(
5240 KeyCode::Down,
5241 crossterm::event::KeyModifiers::NONE,
5242 ));
5243
5244 let action = view.handle_key(KeyEvent::new(
5245 KeyCode::Enter,
5246 crossterm::event::KeyModifiers::NONE,
5247 ));
5248 match action {
5249 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
5250 model,
5251 effort,
5252 previous_effort,
5253 ..
5254 }) => {
5255 assert_eq!(model, "deepseek-v4-flash");
5256 assert_eq!(effort, ReasoningEffort::Max);
5257 assert_eq!(previous_effort, ReasoningEffort::High);
5258 }
5259 other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
5260 }
5261 }
5262
5263 #[test]
5264 fn shift_d_emits_an_explicit_startup_default_save() {
5265 let (mut app, mut config, _lock) = create_test_app();
5266 config.api_key = Some("deepseek-picker-test-key".to_string());
5267 app.model = "deepseek-v4-pro".to_string();
5268 app.auto_model = false;
5269 let mut view = ModelPickerView::new(&app, &config);
5270
5271 let action = view.handle_key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT));
5272
5273 assert!(matches!(
5274 action,
5275 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
5276 save_as_startup_default: true,
5277 ..
5278 })
5279 ));
5280 }
5281
5282 #[test]
5283 fn deepseek_provider_uses_neutral_two_pane_selection() {
5284 let (mut app, config, _lock) = create_test_app();
5285 app.model = "deepseek-v4-flash".to_string();
5286 app.auto_model = false;
5287 app.reasoning_effort = ReasoningEffort::Max;
5288 app.enable_provider_model("deepseek", "deepseek-v4-pro");
5289 app.enable_provider_model("deepseek", "deepseek-v4-flash");
5290 let view = ModelPickerView::new(&app, &config);
5291 assert_eq!(view.selected_model_idx, 2);
5292 assert_eq!(view.selected_effort_idx, 4);
5293 assert_eq!(view.focus, Pane::Model);
5294 assert_eq!(view.resolved_model(), "deepseek-v4-flash");
5295 assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
5296 }
5297
5298 #[test]
5299 fn model_picker_selected_row_renders_readable_selection_contrast() {
5300 let (mut app, mut config, _lock) = create_test_app();
5301 // Selectable rows need credentials so the selection aura is the
5302 // bright contrast treatment rather than the locked muted style.
5303 config.api_key = Some("deepseek-picker-test-key".to_string());
5304 app.model = "deepseek-v4-flash".to_string();
5305 app.auto_model = false;
5306 let view = ModelPickerView::new(&app, &config);
5307 let area = Rect::new(0, 0, 100, 28);
5308 let mut buf = Buffer::empty(area);
5309
5310 view.render(area, &mut buf);
5311
5312 let y = row_containing(&buf, area, "deepseek-v4-flash")
5313 .expect("selected model row should render");
5314 let highlighted_cells = (area.x..area.x.saturating_add(area.width))
5315 .filter(|&x| {
5316 let cell = &buf[(x, y)];
5317 !cell.symbol().trim().is_empty()
5318 && cell.bg == palette::SELECTION_BG
5319 && cell.fg == palette::SELECTION_TEXT
5320 })
5321 .count();
5322
5323 assert!(
5324 highlighted_cells >= "deepseek-v4-flash".len(),
5325 "selected /model row should use readable selection text"
5326 );
5327 assert!(
5328 !(area.x..area.x.saturating_add(area.width))
5329 .any(|x| buf[(x, y)].bg == palette::WHALE_ACTION),
5330 "selected /model row should not use the bright accent background"
5331 );
5332 }
5333
5334 #[test]
5335 fn known_model_with_auto_effort_preserves_explicit_model() {
5336 let (mut app, config, _lock) = create_test_app();
5337 app.model = "deepseek-v4-pro".to_string();
5338 app.auto_model = false;
5339 app.reasoning_effort = ReasoningEffort::Auto;
5340 let view = ModelPickerView::new(&app, &config);
5341 assert!(!view.show_custom_model_row);
5342 assert_eq!(view.selected_model_idx, 1);
5343 assert_eq!(view.selected_effort_idx, 0);
5344 assert_eq!(view.resolved_model(), "deepseek-v4-pro");
5345 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
5346 }
5347
5348 #[test]
5349 fn auto_model_selects_auto_row() {
5350 let (mut app, config, _lock) = create_test_app();
5351 app.model = "auto".to_string();
5352 app.auto_model = true;
5353 app.reasoning_effort = ReasoningEffort::Auto;
5354 let view = ModelPickerView::new(&app, &config);
5355 assert_eq!(view.selected_model_idx, 0);
5356 assert_eq!(view.selected_effort_idx, 0);
5357 assert_eq!(view.resolved_model(), "auto");
5358 assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
5359 }
5360
5361 #[test]
5362 fn custom_model_row_preserves_current_model_and_effort() {
5363 let (mut app, config, _lock) = create_test_app();
5364 app.model = "deepseek-v4-pro-2026-04-XX".to_string();
5365 app.auto_model = false;
5366 app.reasoning_effort = ReasoningEffort::High;
5367 let view = ModelPickerView::new(&app, &config);
5368 assert!(view.show_custom_model_row);
5369 assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
5370 assert_eq!(view.selected_effort_idx, 3);
5371 assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
5372 assert_eq!(view.resolved_effort(), ReasoningEffort::High);
5373 }
5374
5375 #[test]
5376 fn move_down_from_last_model_is_noop() {
5377 let (app, config, _lock) = create_test_app();
5378 let mut view = ModelPickerView::new(&app, &config);
5379 view.selected_model_idx = view.model_row_count() - 1;
5380 let result = view.move_down();
5381 assert!(!result);
5382 }
5383
5384 #[test]
5385 fn move_up_from_first_model_is_noop() {
5386 let (app, config, _lock) = create_test_app();
5387 let mut view = ModelPickerView::new(&app, &config);
5388 view.selected_model_idx = 0;
5389 let result = view.move_up();
5390 assert!(!result);
5391 }
5392
5393 #[test]
5394 fn immediate_esc_closes_without_apply() {
5395 let (app, config, _lock) = create_test_app();
5396 let mut view = ModelPickerView::new(&app, &config);
5397 let action = view.handle_key(KeyEvent::new(
5398 KeyCode::Esc,
5399 crossterm::event::KeyModifiers::NONE,
5400 ));
5401 assert!(matches!(
5402 action,
5403 ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
5404 ));
5405 }
5406
5407 #[test]
5408 fn esc_after_selection_move_closes_without_apply() {
5409 let (mut app, config, _lock) = create_test_app();
5410 app.reasoning_effort = ReasoningEffort::High;
5411 let mut view = ModelPickerView::new(&app, &config);
5412 view.handle_key(KeyEvent::new(
5413 KeyCode::Down,
5414 crossterm::event::KeyModifiers::NONE,
5415 ));
5416
5417 let action = view.handle_key(KeyEvent::new(
5418 KeyCode::Esc,
5419 crossterm::event::KeyModifiers::NONE,
5420 ));
5421
5422 assert!(matches!(
5423 action,
5424 ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
5425 ));
5426 }
5427
5428 #[test]
5429 fn esc_reports_browsing_context_and_reopen_restores_it() {
5430 let (mut app, config, _lock) = create_test_app();
5431 let mut view = ModelPickerView::new(&app, &config);
5432
5433 // Browse: switch to the full catalog and move the highlight down two.
5434 view.handle_key(KeyEvent::new(
5435 KeyCode::Char('a'),
5436 crossterm::event::KeyModifiers::NONE,
5437 ));
5438 view.handle_key(KeyEvent::new(
5439 KeyCode::Down,
5440 crossterm::event::KeyModifiers::NONE,
5441 ));
5442 view.handle_key(KeyEvent::new(
5443 KeyCode::Down,
5444 crossterm::event::KeyModifiers::NONE,
5445 ));
5446 let browsed_id = view.resolved_model();
5447
5448 let action = view.handle_key(KeyEvent::new(
5449 KeyCode::Esc,
5450 crossterm::event::KeyModifiers::NONE,
5451 ));
5452 let ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
5453 catalog_view,
5454 view,
5455 selected_row_id,
5456 }) = action
5457 else {
5458 panic!("expected ModelPickerDismissed, got something else");
5459 };
5460 assert!(catalog_view, "catalog view should be remembered");
5461 assert_eq!(view, "catalog");
5462 assert_eq!(selected_row_id.as_deref(), Some(browsed_id.as_str()));
5463
5464 // Reopen with the memory applied — same view, same highlighted row.
5465 app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
5466 catalog_view,
5467 view: Some(view),
5468 selected_row_id,
5469 });
5470 let reopened = ModelPickerView::new(&app, &config);
5471 assert_eq!(reopened.view, ModelListView::Catalog);
5472 assert_eq!(reopened.resolved_model(), browsed_id);
5473 }
5474
5475 #[test]
5476 fn reopen_with_stale_memory_falls_back_to_active_model() {
5477 let (mut app, config, _lock) = create_test_app();
5478 app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
5479 catalog_view: false,
5480 view: Some("configured".to_string()),
5481 selected_row_id: Some("model-that-no-longer-exists".to_string()),
5482 });
5483 let view = ModelPickerView::new(&app, &config);
5484 // The remembered row is gone; the picker must still open on a valid
5485 // selection (the active model path from the default constructor).
5486 assert!(view.selected_model_idx <= view.model_row_count());
5487 }
5488
5489 /// The four terminal sizes the v0.8.66 modal blocker (#3732) requires every
5490 /// overlay to remain readable and fully operable at.
5491 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
5492
5493 #[test]
5494 fn toggle_view_cycles_six_catalog_views() {
5495 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5496 let (app, config, _lock) = create_test_app();
5497 let mut view = ModelPickerView::new(&app, &config);
5498 let configured_count = view.visible_model_rows().len();
5499 assert_eq!(view.view, ModelListView::Configured);
5500
5501 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
5502 assert_eq!(view.view, ModelListView::Catalog);
5503 assert!(view.visible_model_rows().len() > configured_count);
5504
5505 let expected = [
5506 ModelListView::Recent,
5507 ModelListView::Coding,
5508 ModelListView::Cheap,
5509 ModelListView::LongContext,
5510 ModelListView::Configured,
5511 ];
5512 for expected_view in expected {
5513 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
5514 assert_eq!(view.view, expected_view);
5515 }
5516 assert_eq!(view.visible_model_rows().len(), configured_count);
5517 }
5518
5519 #[test]
5520 fn discoverability_views_do_not_auto_select_newest() {
5521 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5522 let (app, config, _lock) = create_test_app();
5523 let mut view = ModelPickerView::new(&app, &config);
5524 let active = view.resolved_model();
5525 // Cycle to Recent — highlight resets to index 0, but apply still requires Enter.
5526 for _ in 0..2 {
5527 view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
5528 }
5529 assert_eq!(view.view, ModelListView::Recent);
5530 assert_eq!(view.selected_model_idx, 0);
5531 // Esc dismisses without applying a surprising newest route.
5532 let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
5533 assert!(matches!(
5534 action,
5535 ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
5536 ));
5537 assert_eq!(active, app.model);
5538 }
5539
5540 #[test]
5541 fn model_picker_empty_state_and_footer_localize_in_complete_locales() {
5542 use crate::localization::{Locale, MessageId, tr};
5543 use crate::tui::views::ViewStack;
5544
5545 // CJK/emoji cells occupy multiple columns; blank pad cells make a
5546 // naive cell join insert spaces, so compare on whitespace-stripped text.
5547 let compact = |s: &str| -> String { s.chars().filter(|c| !c.is_whitespace()).collect() };
5548
5549 for locale in Locale::shipped_complete() {
5550 let (mut app, config, _lock) = create_test_app();
5551 app.ui_locale = *locale;
5552 // Force an empty model list by using a query that cannot match.
5553 let mut view = ModelPickerView::new(&app, &config);
5554 view.query = "zzz-no-such-model-xyzzy".into();
5555 view.focus = Pane::Model;
5556
5557 let area = Rect::new(0, 0, 80, 24);
5558 let mut buf = Buffer::empty(area);
5559 let mut stack = ViewStack::new();
5560 stack.push(view);
5561 stack.render(area, &mut buf);
5562 let mut text = String::new();
5563 for y in 0..area.height {
5564 for x in 0..area.width {
5565 text.push_str(buf[(x, y)].symbol());
5566 }
5567 }
5568 let expected_match = tr(*locale, MessageId::RouteNoModelMatch)
5569 .replace("{query}", "zzz-no-such-model-xyzzy");
5570 let compact_text = compact(&text);
5571 // Pane width may clip the trailing glyph of a long CJK sentence, so
5572 // assert on the query plus the leading localized clause rather than
5573 // the entire string.
5574 assert!(
5575 compact_text.contains("zzz-no-such-model-xyzzy"),
5576 "{} missing empty-state query; got: {text}",
5577 locale.tag()
5578 );
5579 let leading = expected_match
5580 .split(['—', '–', '-'])
5581 .next()
5582 .unwrap_or(expected_match.as_str());
5583 let leading_compact = compact(leading);
5584 // Require a meaningful prefix so truncated tails still match.
5585 let leading_prefix: String = leading_compact.chars().take(24).collect();
5586 assert!(
5587 !leading_prefix.is_empty() && compact_text.contains(&leading_prefix),
5588 "{} missing localized no-match empty state prefix {leading_prefix:?}; got: {text}",
5589 locale.tag()
5590 );
5591 if *locale != Locale::En {
5592 let en_match = tr(Locale::En, MessageId::RouteNoModelMatch)
5593 .replace("{query}", "zzz-no-such-model-xyzzy");
5594 assert_ne!(
5595 expected_match,
5596 en_match,
5597 "{} empty-state translation must not be English",
5598 locale.tag()
5599 );
5600 assert!(
5601 !compact_text.contains(&compact("No models match")),
5602 "{} leaked English empty-state copy",
5603 locale.tag()
5604 );
5605 }
5606
5607 // Footer action labels must resolve through the locale pack.
5608 for id in [
5609 MessageId::PickerActionMove,
5610 MessageId::PickerActionSwitch,
5611 MessageId::PickerActionApply,
5612 MessageId::PickerActionCancel,
5613 ] {
5614 let label = tr(*locale, id);
5615 assert!(
5616 compact_text.contains(&compact(label.as_ref())),
5617 "{} missing footer label for {id:?}: {label}",
5618 locale.tag()
5619 );
5620 }
5621 }
5622 }
5623
5624 #[test]
5625 fn model_picker_is_usable_and_opaque_at_blocker_sizes() {
5626 use crate::tui::views::ViewStack;
5627 let (app, config, _lock) = create_test_app();
5628 for (w, h) in BLOCKER_SIZES {
5629 let area = Rect::new(0, 0, w, h);
5630 let mut buf = Buffer::empty(area);
5631 // Pre-fill with a sentinel so any cell the composited modal fails to
5632 // paint (bleed-through) is detectable as a surviving 'X'. The default
5633 // test app uses DeepSeek model ids, so 'X' never appears legitimately.
5634 for y in 0..h {
5635 for x in 0..w {
5636 buf[(x, y)].set_symbol("X");
5637 }
5638 }
5639 // Render through the ViewStack so the shared opaque backdrop is
5640 // painted exactly as it is in production.
5641 let mut stack = ViewStack::new();
5642 stack.push(ModelPickerView::new(&app, &config));
5643 stack.render(area, &mut buf);
5644
5645 let rows: Vec<String> = (0..h)
5646 .map(|y| {
5647 (0..w)
5648 .map(|x| buf[(x, y)].symbol().to_string())
5649 .collect::<String>()
5650 })
5651 .collect();
5652 let text = rows.join("\n");
5653
5654 // Footer keeps every action (it wraps instead of clipping).
5655 for label in [
5656 "move",
5657 "switch",
5658 "search any model",
5659 "apply",
5660 "browse catalog",
5661 "cancel",
5662 ] {
5663 assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
5664 }
5665 // The shared list/detail layout keeps both picker panes visible;
5666 // narrow blocker sizes stack them instead of squeezing columns.
5667 for label in ["Model", "Thinking"] {
5668 assert!(text.contains(label), "{w}x{h}: missing '{label}' pane");
5669 }
5670 // Composited frame is fully opaque: no sentinel survives and the
5671 // center cell carries the modal ink background.
5672 assert!(
5673 !text.contains('X'),
5674 "{w}x{h}: background bleed-through into modal surface"
5675 );
5676 assert_eq!(
5677 buf[(w / 2, h / 2)].bg,
5678 palette::WHALE_BG,
5679 "{w}x{h}: modal interior must be opaque"
5680 );
5681 // No row exceeds the frame width (no horizontal overflow).
5682 for (y, row) in rows.iter().enumerate() {
5683 assert!(
5684 unicode_width::UnicodeWidthStr::width(row.trim_end()) <= w as usize,
5685 "{w}x{h}: row {y} overflows width: {row:?}"
5686 );
5687 }
5688 }
5689 }
5690
5691 #[test]
5692 fn deepseek_picker_exposes_the_documented_wire_ladder() {
5693 let labels: Vec<&str> = picker_efforts_for_route(
5694 crate::config::ApiProvider::Deepseek,
5695 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
5696 "deepseek-v4-pro",
5697 false,
5698 )
5699 .iter()
5700 .map(|effort| effort.short_label())
5701 .collect();
5702 // First-party DeepSeek documents a real `low` wire tier (#52); medium
5703 // stays hidden because the wire has no medium value.
5704 assert_eq!(labels, vec!["auto", "off", "low", "high", "max"]);
5705 }
5706
5707 #[test]
5708 fn catalog_effort_values_map_provider_vocabularies() {
5709 assert_eq!(catalog_effort_value("none"), Some(ReasoningEffort::Off));
5710 assert_eq!(
5711 catalog_effort_value("minimal"),
5712 Some(ReasoningEffort::Minimal)
5713 );
5714 assert_eq!(
5715 catalog_effort_value("adaptive"),
5716 Some(ReasoningEffort::Auto)
5717 );
5718 assert_eq!(catalog_effort_value("xhigh"), Some(ReasoningEffort::XHigh));
5719 assert_eq!(
5720 catalog_effort_value("always_on"),
5721 Some(ReasoningEffort::Max)
5722 );
5723 assert_eq!(catalog_effort_value("mystery"), None);
5724 }
5725
5726 #[test]
5727 fn picker_uses_catalog_reasoning_options_when_present() {
5728 // GLM-5.2 ships effort values high/max in the bundled Models.dev catalog.
5729 let labels: Vec<&str> = picker_efforts_for_route(
5730 crate::config::ApiProvider::Zai,
5731 crate::config::ApiProvider::Zai.default_base_url(),
5732 "GLM-5.2",
5733 false,
5734 )
5735 .iter()
5736 .map(|effort| effort.as_setting())
5737 .collect();
5738 assert_eq!(
5739 labels,
5740 vec!["auto", "high", "max"],
5741 "catalog effort list must drive the Thinking pane (plus Auto)"
5742 );
5743 }
5744
5745 #[test]
5746 fn picker_uses_catalog_thinking_options_for_minimax() {
5747 let labels: Vec<&str> = picker_efforts_for_route(
5748 crate::config::ApiProvider::Minimax,
5749 crate::config::ApiProvider::Minimax.default_base_url(),
5750 "MiniMax-M3",
5751 false,
5752 )
5753 .iter()
5754 .map(|effort| effort.as_setting())
5755 .collect();
5756 // adaptive→auto, disabled→off; Auto is already first so no duplicate.
5757 assert_eq!(labels, vec!["auto", "off"]);
5758 }
5759
5760 #[test]
5761 fn picker_keeps_default_when_catalog_has_no_reasoning_options() {
5762 // grok-4.5 is reasoning-capable but ships no reasoning_options list.
5763 let labels: Vec<&str> = picker_efforts_for_route(
5764 crate::config::ApiProvider::Xai,
5765 crate::config::ApiProvider::Xai.default_base_url(),
5766 "grok-4.5",
5767 false,
5768 )
5769 .iter()
5770 .map(|effort| effort.as_setting())
5771 .collect();
5772 assert_eq!(
5773 labels,
5774 vec!["auto", "off", "high", "max"],
5775 "absent reasoning_options must keep DEFAULT_PICKER_EFFORTS, not invent Low/Medium"
5776 );
5777 }
5778
5779 #[test]
5780 fn single_visible_row_pane_title_shows_single_position_not_degenerate_range() {
5781 let (app, config, _lock) = create_test_app();
5782 let view = ModelPickerView::new(&app, &config);
5783
5784 // Three rows in a pane only tall enough to show one row (height 2
5785 // leaves 1 row after the hairline title). The scrollable-title branch
5786 // must render a single position (`Model 2/3`), not a degenerate `2-2/3`
5787 // range (#3995).
5788 let rows: Vec<PaneRow> = (1..=3)
5789 .map(|n| PaneRow::effort(format!("model-{n}"), String::new()))
5790 .collect();
5791 let area = Rect::new(0, 0, 40, 2);
5792 let mut buf = Buffer::empty(area);
5793 view.render_pane(
5794 area,
5795 &mut buf,
5796 "Model",
5797 rows,
5798 PaneRenderState {
5799 pane: Pane::Model,
5800 selected: 1,
5801 focused: false,
5802 },
5803 );
5804
5805 let title = buffer_row_text(&buf, area, area.y);
5806 assert!(
5807 title.contains("Model 2/3"),
5808 "single visible row should show a single position: {title:?}"
5809 );
5810 assert!(
5811 !title.contains("2-2/3"),
5812 "single visible row must not render a degenerate range: {title:?}"
5813 );
5814 }
5815
5816 #[test]
5817 fn multi_visible_row_pane_title_keeps_real_range() {
5818 let (app, config, _lock) = create_test_app();
5819 let view = ModelPickerView::new(&app, &config);
5820
5821 // Four rows in a pane tall enough for two inner rows (height 3). The
5822 // visible window spans two rows, so the title keeps a real range.
5823 let rows: Vec<PaneRow> = (1..=4)
5824 .map(|n| PaneRow::effort(format!("model-{n}"), String::new()))
5825 .collect();
5826 let area = Rect::new(0, 0, 40, 3);
5827 let mut buf = Buffer::empty(area);
5828 view.render_pane(
5829 area,
5830 &mut buf,
5831 "Thinking",
5832 rows,
5833 PaneRenderState {
5834 pane: Pane::Effort,
5835 selected: 2,
5836 focused: false,
5837 },
5838 );
5839
5840 let title = buffer_row_text(&buf, area, area.y);
5841 assert!(
5842 title.contains("Thinking 2-3/4"),
5843 "multi visible row should render a real range: {title:?}"
5844 );
5845 }
5846
5847 #[test]
5848 fn route_discriminator_handles_ascii_and_non_ascii_display_names() {
5849 // ASCII: the endpoint tail after the display name's alphanumerics.
5850 assert_eq!(
5851 route_discriminator("DeepSeek", "deepseek-anthropic"),
5852 Some("anthropic".to_string())
5853 );
5854 assert_eq!(route_discriminator("DeepSeek", "deepseek"), None);
5855 // Non-ASCII display: `consumed` is a char count, so comparing it to a
5856 // byte length would over-consume and drop the tail. The multibyte
5857 // prefix is matched char-for-char and the ASCII tail survives.
5858 assert_eq!(
5859 route_discriminator("深度求索", "深度求索-anthropic"),
5860 Some("anthropic".to_string())
5861 );
5862 }
5863 }
5864
5864 lines RUST