返回 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::{Ref, 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::{CatalogRefreshError, CatalogSource, CatalogStatus};
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::{Config, DEEPSEEK_ALIAS_REPLACEMENT, ProviderKind};
31 use crate::model_profile::{
32 CapabilityOverride, SupportState, resolved_capability_profile_for_route_with_overrides,
33 resolved_capability_profile_with_overrides,
34 };
35 use crate::models_dev_live::{self, ModelsDevFreshness};
36 use crate::provider_lake::{
37 catalog_offering_for_model, catalog_offering_for_model_identity, configured_providers,
38 };
39 use crate::reasoning_preference::ReasoningEffort;
40 use crate::settings::PinnedModel;
41 use crate::tui::app::{App, StatusToastLevel};
42 use crate::tui::menu_style;
43 use crate::tui::views::fleet_detail::{FleetRouteSelection, FleetRouteTarget};
44 use crate::tui::views::{
45 ActionHint, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
46 render_underwater_surface,
47 };
48 use crate::utils::format_context_window;
49 use codewhale_localization::{Locale, MessageId, tr};
50 use codewhale_palette as palette;
51
52 /// Thinking-effort rows shown for DeepSeek-style providers, in the order
53 /// DeepSeek behaviorally distinguishes them.
54 const DEFAULT_PICKER_EFFORTS: &[ReasoningEffort] = &[
55 ReasoningEffort::Auto,
56 ReasoningEffort::Off,
57 ReasoningEffort::High,
58 ReasoningEffort::Max,
59 ];
60 /// First-party DeepSeek routes document a real `low` wire tier alongside
61 /// `high`/`max` (#52), so their picker exposes the cheaper tier the generic
62 /// default list cannot claim for routes where low collapses onto high.
63 const DEEPSEEK_PICKER_EFFORTS: &[ReasoningEffort] = &[
64 ReasoningEffort::Auto,
65 ReasoningEffort::Off,
66 ReasoningEffort::Low,
67 ReasoningEffort::High,
68 ReasoningEffort::Max,
69 ];
70 /// Kimi Code K3 accepts route-specific low and medium controls at the
71 /// official membership endpoint. Medium becomes K3's nested high wire effort,
72 /// but keeping the selected intent visible is important for recovery and
73 /// route receipts. K3 is always-thinking, so `off` would only land on `low`
74 /// and is not offered.
75 const KIMI_CODE_K3_PICKER_EFFORTS: &[ReasoningEffort] = &[
76 ReasoningEffort::Auto,
77 ReasoningEffort::Low,
78 ReasoningEffort::Medium,
79 ReasoningEffort::High,
80 ReasoningEffort::Max,
81 ];
82 const CODEX_PICKER_EFFORTS: &[ReasoningEffort] = &[
83 ReasoningEffort::Low,
84 ReasoningEffort::Medium,
85 ReasoningEffort::High,
86 ReasoningEffort::Max,
87 ];
88 /// Auto model routing has no concrete provider dialect yet, so retain the
89 /// complete preference vocabulary and defer normalization to dispatch.
90 const AUTO_MODEL_PICKER_EFFORTS: &[ReasoningEffort] = &[
91 ReasoningEffort::Auto,
92 ReasoningEffort::Off,
93 ReasoningEffort::Low,
94 ReasoningEffort::Medium,
95 ReasoningEffort::High,
96 ReasoningEffort::Max,
97 ];
98
99 /// `/model` catalog views (#4115).
100 ///
101 /// Configured stays the calm default: the current route, pins and Fleet
102 /// models, recently used routes, and one default per credentialed provider
103 /// (#6533). Typing searches every provider and a cross-provider selection
104 /// switches its route transactionally, so `/provider` is never a
105 /// prerequisite. Discoverability views (New / Coding / Cheap / Long context)
106 /// never auto-select a surprising route — the active model remains the
107 /// selection until the operator moves. "New" sorts by catalog refresh time;
108 /// recency of *use* lives in the default view.
109 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
110 enum ModelListView {
111 Configured,
112 Catalog,
113 New,
114 Coding,
115 Cheap,
116 LongContext,
117 }
118
119 impl ModelListView {
120 const ALL: [Self; 6] = [
121 Self::Configured,
122 Self::Catalog,
123 Self::New,
124 Self::Coding,
125 Self::Cheap,
126 Self::LongContext,
127 ];
128
129 fn next(self) -> Self {
130 let idx = Self::ALL.iter().position(|view| *view == self).unwrap_or(0);
131 Self::ALL[(idx + 1) % Self::ALL.len()]
132 }
133
134 fn from_memory_name(name: &str) -> Option<Self> {
135 match name {
136 "configured" => Some(Self::Configured),
137 "catalog" => Some(Self::Catalog),
138 // `recent` is the pre-#6533 name of the same view.
139 "new" | "recent" => Some(Self::New),
140 "coding" => Some(Self::Coding),
141 "cheap" => Some(Self::Cheap),
142 "long_context" => Some(Self::LongContext),
143 _ => None,
144 }
145 }
146
147 fn memory_name(self) -> &'static str {
148 match self {
149 Self::Configured => "configured",
150 Self::Catalog => "catalog",
151 Self::New => "new",
152 Self::Coding => "coding",
153 Self::Cheap => "cheap",
154 Self::LongContext => "long_context",
155 }
156 }
157
158 /// Short chrome / action label for this view.
159 fn title_label(self) -> &'static str {
160 match self {
161 Self::Configured => "configured",
162 Self::Catalog => "catalog",
163 Self::New => "new",
164 Self::Coding => "coding",
165 Self::Cheap => "cheap",
166 Self::LongContext => "long ctx",
167 }
168 }
169
170 /// Views that browse beyond the conservative configured-provider set.
171 fn is_discoverability(self) -> bool {
172 !matches!(self, Self::Configured)
173 }
174
175 fn browses_all_providers(self) -> bool {
176 self.is_discoverability()
177 }
178 }
179
180 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
181 enum Pane {
182 Model,
183 Effort,
184 }
185
186 /// What applying a row means: the session's own route (`/model`), or a pin
187 /// on one Fleet editor row, where Enter hands the absolute route back to the
188 /// editor instead of switching the session.
189 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
190 pub(crate) enum ModelPickerPurpose {
191 Session,
192 FleetProfileRoute {
193 editor_id: uuid::Uuid,
194 initial_reasoning: Option<ReasoningEffort>,
195 },
196 FleetRoute {
197 target: FleetRouteTarget,
198 editor_id: uuid::Uuid,
199 initial_reasoning: Option<ReasoningEffort>,
200 allow_inherit: bool,
201 },
202 }
203
204 #[derive(Debug, Clone, Copy)]
205 struct PaneRenderState {
206 pane: Pane,
207 selected: usize,
208 focused: bool,
209 }
210
211 pub struct ModelPickerView {
212 initial_model: String,
213 /// Exact runtime value before the picker opened. Keep this raw so choosing
214 /// the canonical replacement for a retired alias performs a real migration
215 /// instead of being misclassified as "unchanged".
216 previous_model: String,
217 initial_provider: ProviderKind,
218 initial_provider_identity: String,
219 initial_identity: Option<crate::config::ProviderIdentity>,
220 /// Raw preference before the picker opened. An absent explicit preference
221 /// is represented by Auto so applying a visible fixed-route tier is still
222 /// recognized as an intentional picker choice.
223 initial_effort: ReasoningEffort,
224 /// Working raw preference. Model-row navigation only changes how this is
225 /// projected into the visible route-specific effort rows.
226 selected_effort_request: ReasoningEffort,
227 active_accepts_custom_model_ids: bool,
228 query: String,
229 /// Working selection (separate from the initial values so we can offer a
230 /// clean Esc-to-cancel without mutating App state).
231 selected_model_idx: usize,
232 selected_effort_idx: usize,
233 focus: Pane,
234 /// True when the active model is one we don't list — we still show it
235 /// so the picker doesn't quietly forget the user's chosen IDs.
236 show_custom_model_row: bool,
237 model_rows: Vec<ModelPickerRow>,
238 /// Static route facts used to validate custom/current rows at apply time.
239 route_config: Config,
240 /// Session-local provider checks used by custom/current rows. Catalog rows
241 /// resolve the same snapshot during construction.
242 provider_health: crate::provider_readiness::ProviderReadinessSnapshot,
243 view: ModelListView,
244 /// Other providers considered "configured" (#3830), shown by default
245 /// alongside `initial_provider`'s own rows without requiring the user to
246 /// type a search query first. Uses the same definition as the
247 /// `/provider` manager's default view
248 /// (`crate::config::provider_is_configured_for_active`): active
249 /// provider, working credentials/OAuth, or an explicit
250 /// `[providers.<name>]` entry. Self-hosted providers (Ollama/Sglang/
251 /// Vllm) don't qualify just because routing to them doesn't require a
252 /// key.
253 configured_providers: Vec<crate::config::ProviderIdentity>,
254 row_hitboxes: RefCell<Vec<(Rect, Pane, usize)>>,
255 last_mouse_selected: Option<(Pane, usize)>,
256 hovered_row: Option<(Pane, usize)>,
257 /// UI locale captured from the app at construction (#4057 wave 2).
258 locale: Locale,
259 pinned_models: Vec<PinnedModel>,
260 // Navigation only changes selection. Catalog projections are rebuilt when
261 // the query, view, sort, pins or readiness/catalog snapshot changes.
262 projection: RefCell<Option<ModelPickerProjection>>,
263 sort: Option<ModelSort>,
264 column_hitboxes: RefCell<Vec<(Rect, ModelSortColumn)>>,
265 pane_hitboxes: RefCell<Vec<(Rect, Pane)>>,
266 catalog_action_hitbox: RefCell<Option<Rect>>,
267 catalog_action_hovered: bool,
268 purpose: ModelPickerPurpose,
269 assignment_context: Option<(String, String)>,
270 /// Receipt of the last ⇧P / ⇧F / refresh with its typed toast level. The
271 /// picker covers the status line, so this line is the only place those
272 /// actions can confirm or refuse (#6500). Cleared by the next key.
273 notice: Option<(String, StatusToastLevel)>,
274 /// Theme the notice resolves its semantic ink through.
275 theme: codewhale_palette::UiTheme,
276 }
277
278 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
279 enum ModelSortColumn {
280 Model,
281 Provider,
282 Context,
283 }
284
285 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
286 struct ModelSort {
287 column: ModelSortColumn,
288 descending: bool,
289 }
290
291 struct ModelPickerProjection {
292 query: String,
293 view: ModelListView,
294 sort: Option<ModelSort>,
295 indices: Vec<usize>,
296 rows: Vec<PaneRow>,
297 custom: Option<(String, crate::config::ProviderIdentity)>,
298 }
299
300 struct VisibleModelRows<'a> {
301 catalog: &'a [ModelPickerRow],
302 indices: Ref<'a, [usize]>,
303 }
304
305 impl<'a> VisibleModelRows<'a> {
306 fn len(&self) -> usize {
307 self.indices.len()
308 }
309 fn get(&self, index: usize) -> Option<&'a ModelPickerRow> {
310 self.indices.get(index).map(|index| &self.catalog[*index])
311 }
312 fn iter(&self) -> impl ExactSizeIterator<Item = &'a ModelPickerRow> + '_ {
313 self.indices.iter().map(|index| &self.catalog[*index])
314 }
315 }
316
317 impl std::ops::Index<usize> for VisibleModelRows<'_> {
318 type Output = ModelPickerRow;
319 fn index(&self, index: usize) -> &Self::Output {
320 &self.catalog[self.indices[index]]
321 }
322 }
323
324 #[derive(Debug, Clone, PartialEq, Eq)]
325 struct ModelPickerRow {
326 id: String,
327 provider: Option<ProviderKind>,
328 /// Concrete persistence identity. `Custom` alone cannot identify a named
329 /// custom route, so pins must carry this exact key when present.
330 provider_identity: Option<String>,
331 admitted_identity: Option<crate::config::ProviderIdentity>,
332 hint: String,
333 metadata: EffectivePickerMetadata,
334 selectable: bool,
335 /// Why this route cannot be attempted, kept structured so the scannable
336 /// row can show the reason without re-parsing the prose `hint`. `None`
337 /// whenever the route is attemptable.
338 blocked_reason: Option<String>,
339 /// Where this row sits in the default view (#6533); `None` keeps it
340 /// behind search and the catalog views.
341 default_rank: Option<(DefaultSection, usize)>,
342 /// The route holds a credential (saved key, imported token, or an
343 /// observed success), as opposed to merely being routable.
344 credentialed: bool,
345 /// The route's fresh live roster does not list this id.
346 not_listed: Option<NotListed>,
347 }
348
349 /// Sections of the default view, in display order (#6533).
350 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
351 enum DefaultSection {
352 Current,
353 Pinned,
354 Recent,
355 ProviderDefault,
356 }
357
358 /// Most rows the default view's recent section shows.
359 const RECENT_ROW_LIMIT: usize = 8;
360
361 /// A fresh live roster for the row's exact route omits its id. Only a fresh
362 /// roster may say so: a stale one lags the provider (and would condemn ids it
363 /// simply never saw). The row stays selectable and nothing is rewritten.
364 #[derive(Debug, Clone, PartialEq, Eq)]
365 struct NotListed {
366 provider: String,
367 /// `YYYY-MM-DD` of the roster fetch.
368 checked: String,
369 }
370
371 #[derive(Debug, Clone, PartialEq, Eq, Default)]
372 struct EffectivePickerMetadata {
373 display_name: Option<String>,
374 declared_input_price: Option<String>,
375 context_window: Option<u32>,
376 /// The context window came through the legacy provider fallback rather
377 /// than an offering, catalog row, roster, or operator override — shown,
378 /// but never as a verified capability (#5239, #5441).
379 context_window_unverified: bool,
380 max_output: Option<u32>,
381 /// The output ceiling is an assumed floor for a route that publishes no
382 /// ceiling we can stand behind (unknown Anthropic-family models),
383 /// clamped but never labeled "documented" (#5440).
384 max_output_unverified: bool,
385 tool_calls: Option<bool>,
386 reasoning: bool,
387 reasoning_unknown: bool,
388 vision: SupportState,
389 pricing: PickerPricing,
390 source: Option<CatalogSource>,
391 }
392
393 #[derive(Debug, Clone, PartialEq, Eq, Default)]
394 enum PickerPricing {
395 /// The route explicitly does not expose authoritative token pricing.
396 Unavailable,
397 Known(String),
398 #[default]
399 Unknown,
400 }
401
402 impl ModelPickerView {
403 #[must_use]
404 /// The same picker, opened by the Fleet editor for one of its rows: Enter
405 /// hands the absolute route to the editor (`FleetRoutePicked`) instead of
406 /// switching the session.
407 pub fn new_for_fleet_route(
408 app: &App,
409 config: &Config,
410 target: FleetRouteTarget,
411 editor_id: uuid::Uuid,
412 selection: FleetRouteSelection,
413 ) -> Self {
414 Self::new_for_assignment(
415 app,
416 config,
417 ModelPickerPurpose::FleetRoute {
418 target,
419 editor_id,
420 initial_reasoning: selection.reasoning,
421 allow_inherit: selection.allow_inherit,
422 },
423 selection,
424 )
425 }
426
427 pub fn new_for_fleet_profile(
428 app: &App,
429 config: &Config,
430 editor_id: uuid::Uuid,
431 selection: FleetRouteSelection,
432 ) -> Self {
433 Self::new_for_assignment(
434 app,
435 config,
436 ModelPickerPurpose::FleetProfileRoute {
437 editor_id,
438 initial_reasoning: selection.reasoning,
439 },
440 selection,
441 )
442 }
443
444 pub fn with_assignment_context(
445 mut self,
446 title: impl Into<String>,
447 scope: impl Into<String>,
448 ) -> Self {
449 self.assignment_context = Some((title.into(), scope.into()));
450 self
451 }
452
453 fn new_for_assignment(
454 app: &App,
455 config: &Config,
456 purpose: ModelPickerPurpose,
457 selection: FleetRouteSelection,
458 ) -> Self {
459 let mut picker = Self::new(app, config);
460 picker.purpose = purpose;
461 // Session browsing memory is unrelated to the row being edited.
462 picker.query.clear();
463 picker.view = ModelListView::Configured;
464 picker.sort = None;
465 picker.initial_provider_identity = selection
466 .provider
467 .unwrap_or_else(|| app.provider_identity_for_persistence().to_string());
468 picker.initial_identity = config
469 .legacy_selection_identity(&picker.initial_provider_identity)
470 .ok();
471 picker.initial_provider = picker
472 .initial_identity
473 .as_ref()
474 .map_or(ProviderKind::Custom, |identity| identity.provider);
475 picker.initial_model = selection.model.unwrap_or_else(|| "auto".to_string());
476 picker.previous_model = picker.initial_model.clone();
477 picker.initial_effort = selection.reasoning.unwrap_or(ReasoningEffort::Auto);
478 picker.selected_effort_request = picker.initial_effort;
479 picker.apply_fleet_route_rows(app, config);
480 *picker.projection.borrow_mut() = None;
481 picker.selected_model_idx = {
482 let rows = picker.visible_model_rows();
483 rows.iter()
484 .position(|row| {
485 row.id == picker.initial_model
486 && model_row_matches_route(
487 row,
488 picker.initial_provider,
489 &picker.initial_provider_identity,
490 )
491 })
492 .unwrap_or(rows.len())
493 };
494 let visible_len = picker.visible_model_rows().len();
495 picker.show_custom_model_row = picker.selected_model_idx >= visible_len;
496 *picker.projection.borrow_mut() = None;
497 picker.select_effort_for_current_model();
498 picker
499 }
500
501 fn apply_fleet_route_rows(&mut self, app: &App, config: &Config) {
502 let allow_inherit = match self.purpose {
503 ModelPickerPurpose::Session => return,
504 ModelPickerPurpose::FleetRoute { allow_inherit, .. } => allow_inherit,
505 ModelPickerPurpose::FleetProfileRoute { .. } => true,
506 };
507 if let Some(identity) = self.initial_identity.as_ref()
508 && self
509 .route_config
510 .scope_to_provider_identity(identity)
511 .is_err()
512 {
513 self.initial_identity = None;
514 }
515 self.configured_providers = configured_providers(config)
516 .into_iter()
517 .filter(|identity| Some(identity) != self.initial_identity.as_ref())
518 .collect();
519 self.active_accepts_custom_model_ids = self
520 .initial_identity
521 .as_ref()
522 .is_some_and(|identity| config.model_ids_pass_through_for_provider(identity));
523 if self.initial_model != "auto"
524 && let Some(identity) = self.initial_identity.as_ref()
525 {
526 push_provider_model_rows(
527 &mut self.model_rows,
528 identity,
529 vec![self.initial_model.clone()],
530 self.initial_provider,
531 config,
532 &codex_model_cache::model_roster_for(config),
533 &app.provider_health,
534 );
535 }
536 self.model_rows
537 .retain(|row| allow_inherit || row.id != "auto");
538 for row in &mut self.model_rows {
539 if row.id == "auto" && row.provider.is_none() {
540 row.hint = format!("{}/{}", app.provider_identity_for_persistence(), app.model);
541 row.selectable = true;
542 row.blocked_reason = None;
543 }
544 }
545 // Same sections as the session picker, anchored on the row being
546 // edited rather than on the session's own route.
547 let current = (self.initial_model != "auto").then_some((
548 self.initial_provider_identity.as_str(),
549 self.initial_model.as_str(),
550 ));
551 assign_default_sections(
552 &mut self.model_rows,
553 app,
554 config,
555 current,
556 &self.pinned_models,
557 );
558 }
559
560 pub fn new(app: &App, config: &Config) -> Self {
561 let initial_model = if app.auto_model {
562 "auto".to_string()
563 } else {
564 picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids())
565 .to_string()
566 };
567 let previous_model = if app.auto_model {
568 "auto".to_string()
569 } else {
570 app.model.clone()
571 };
572 let model_rows = picker_model_rows_for_app(app, config);
573 let configured_providers: Vec<_> = configured_providers(config)
574 .into_iter()
575 .filter(|identity| app.admitted_provider_identity().ok() != Some(identity))
576 .collect();
577 let mut default_visible_rows: Vec<_> = model_rows
578 .iter()
579 .filter(|row| model_row_visible_in_view(row, ModelListView::Configured))
580 .collect();
581 // Selection indices must be calculated in the same order that the
582 // configured view renders. Pinned rows are sorted to the top by
583 // `visible_model_rows`; using the unsorted construction order here
584 // made the cursor land on a different row (or look unselected) after
585 // a pin reordered the list.
586 let pins = picker_pins_for_app(app);
587 sort_model_rows_for_view(
588 &mut default_visible_rows,
589 |row| *row,
590 ModelListView::Configured,
591 &pins,
592 );
593 let mut selected_model_idx = default_visible_rows.iter().position(|row| {
594 row.id == initial_model
595 && model_row_matches_route(
596 row,
597 app.api_provider,
598 app.provider_identity_for_persistence(),
599 )
600 });
601 let show_custom_model_row = selected_model_idx.is_none();
602 if show_custom_model_row {
603 selected_model_idx = Some(default_visible_rows.len());
604 }
605 let selected_model_idx = selected_model_idx.unwrap_or(0);
606
607 let initial_effort = app
608 .reasoning_effort_preference
609 .unwrap_or(ReasoningEffort::Auto);
610 let selected_effort_request = app
611 .reasoning_effort_preference
612 .unwrap_or(app.reasoning_effort);
613 let effort_rows = picker_efforts_for_route(
614 app.api_provider,
615 &config.active_route_base_url(),
616 &initial_model,
617 app.auto_model,
618 );
619 let normalized = normalize_picker_effort(
620 selected_effort_request,
621 app.api_provider,
622 &config.active_route_base_url(),
623 &initial_model,
624 app.auto_model,
625 );
626 let selected_effort_idx = effort_rows
627 .iter()
628 .position(|e| *e == normalized)
629 .unwrap_or_else(|| {
630 default_picker_effort_idx(
631 app.api_provider,
632 &config.active_route_base_url(),
633 &initial_model,
634 app.auto_model,
635 )
636 });
637
638 let mut view = Self {
639 initial_model,
640 previous_model,
641 initial_provider: app.api_provider,
642 initial_provider_identity: app.provider_identity_for_persistence().to_string(),
643 initial_identity: app.admitted_provider_identity().ok().cloned(),
644 initial_effort,
645 selected_effort_request,
646 active_accepts_custom_model_ids: app.accepts_custom_model_ids(),
647 query: String::new(),
648 selected_model_idx,
649 selected_effort_idx,
650 focus: Pane::Model,
651 show_custom_model_row,
652 model_rows,
653 route_config: config.clone(),
654 provider_health: app.provider_health.clone(),
655 view: ModelListView::Configured,
656 configured_providers,
657 row_hitboxes: RefCell::new(Vec::new()),
658 last_mouse_selected: None,
659 hovered_row: None,
660 locale: app.ui_locale,
661 pinned_models: pins,
662 projection: RefCell::new(None),
663 sort: None,
664 column_hitboxes: RefCell::new(Vec::new()),
665 pane_hitboxes: RefCell::new(Vec::new()),
666 catalog_action_hitbox: RefCell::new(None),
667 catalog_action_hovered: false,
668 purpose: ModelPickerPurpose::Session,
669 assignment_context: None,
670 notice: None,
671 theme: app.ui_theme,
672 };
673 view.restore_memory(app.model_picker_memory.as_ref());
674 view
675 }
676
677 /// Restore the browsing context from the last dismissed picker (#4109):
678 /// the named catalog view and, when the remembered row still exists in
679 /// that view, the highlighted row. The active model remains the selection
680 /// when nothing was remembered or the row is gone.
681 fn restore_memory(&mut self, memory: Option<&crate::tui::app::ModelPickerMemory>) {
682 let Some(memory) = memory else {
683 return;
684 };
685 if let Some(view_name) = memory.view.as_deref() {
686 if let Some(view) = ModelListView::from_memory_name(view_name) {
687 self.view = view;
688 }
689 } else if memory.catalog_view {
690 self.view = ModelListView::Catalog;
691 }
692 // Older picker memory stores only a model id. An ambiguous id must
693 // not move the selection onto a different configured route. Resolve
694 // the active row again because a restored view can change row order.
695 let position = {
696 let rows = self.visible_model_rows();
697 let remembered = memory.selected_row_id.as_deref().and_then(|remembered_id| {
698 let mut matches = rows
699 .iter()
700 .enumerate()
701 .filter(|(_, row)| row.id == remembered_id);
702 let first = matches.next().map(|(index, _)| index);
703 first.filter(|_| matches.next().is_none())
704 });
705 remembered.or_else(|| {
706 rows.iter().position(|row| {
707 row.id == self.initial_model
708 && model_row_matches_route(
709 row,
710 self.initial_provider,
711 &self.initial_provider_identity,
712 )
713 })
714 })
715 };
716 if let Some(position) = position {
717 self.selected_model_idx = position;
718 self.select_effort_for_current_model();
719 }
720 self.clamp_model_selection();
721 }
722
723 fn ensure_projection(&self) {
724 if self.projection.borrow().as_ref().is_some_and(|cached| {
725 cached.query == self.query && cached.view == self.view && cached.sort == self.sort
726 }) {
727 return;
728 }
729 let query = self.query.trim();
730 let mut indices: Vec<usize> = self
731 .model_rows
732 .iter()
733 .enumerate()
734 .filter_map(|(index, row)| {
735 let visible = if query.is_empty() {
736 model_row_visible_in_view(row, self.view)
737 } else {
738 model_row_matches_query(row, query, self.initial_provider)
739 };
740 visible.then_some(index)
741 })
742 .collect();
743 if let Some(sort) = self.sort {
744 sort_model_indices(&mut indices, &self.model_rows, sort);
745 } else if query.is_empty() {
746 sort_model_rows_for_view(
747 &mut indices,
748 |index| &self.model_rows[*index],
749 self.view,
750 &self.pinned_models,
751 );
752 } else {
753 let query_lower = query.to_ascii_lowercase();
754 indices.sort_by_cached_key(|index| {
755 let row = &self.model_rows[*index];
756 let provider_matches = row.provider.is_some_and(|provider| {
757 row.provider_identity.as_deref().is_some_and(|identity| {
758 identity.to_ascii_lowercase().contains(&query_lower)
759 }) || provider
760 .as_str()
761 .to_ascii_lowercase()
762 .contains(&query_lower)
763 || row_provider_label(row)
764 .to_ascii_lowercase()
765 .contains(&query_lower)
766 });
767 let id = row.id.to_ascii_lowercase();
768 let id_rank = if id == query_lower {
769 0
770 } else if id.starts_with(&query_lower) {
771 1
772 } else {
773 2
774 };
775 (
776 usize::from(!provider_matches),
777 id_rank,
778 usize::from(
779 row.provider.is_some() && row.provider != Some(self.initial_provider),
780 ),
781 id,
782 )
783 });
784 }
785 let visible: Vec<_> = indices
786 .iter()
787 .map(|index| &self.model_rows[*index])
788 .collect();
789 let route_labels = route_labels_for_rows(&visible);
790 // The default view is ordered by section (current, pinned, recent,
791 // defaults), so family headers would split it; only the catalog groups.
792 let grouped =
793 self.sort.is_none() && query.is_empty() && self.view == ModelListView::Catalog;
794 let mut rows: Vec<_> = visible
795 .iter()
796 .map(|row| (row, pin_for_row(&self.pinned_models, row)))
797 .map(|(row, pin)| PaneRow {
798 primary: if self.purpose != ModelPickerPurpose::Session
799 && row.id == "auto"
800 && row.provider.is_none()
801 {
802 tr(self.locale, MessageId::FleetRouteInherited).into_owned()
803 } else {
804 row.metadata
805 .display_name
806 .as_ref()
807 .map(|label| format!("{label} ({})", row.id))
808 .unwrap_or_else(|| row.id.clone())
809 },
810 route: row
811 .provider
812 .map(|provider| {
813 route_labels
814 .get(row_provider_identity(row).unwrap_or(provider.as_str()))
815 .cloned()
816 .unwrap_or_else(|| row_provider_label(row).to_string())
817 })
818 .unwrap_or_default(),
819 meta: if row.provider.is_none() {
820 vec![row.hint.clone()]
821 } else {
822 // A pin or Fleet membership leads the chips so a squeezed
823 // row never sheds it: without it ⇧P / ⇧F changed nothing
824 // the person could see (#6500). A not-listed notice still
825 // leads when there is no pin.
826 let mut chips = model_row_meta_chips(row);
827 if let Some(not_listed) = &row.not_listed {
828 chips.insert(
829 0,
830 tr(self.locale, MessageId::ModelPickerNotListed)
831 .replace("{provider}", &not_listed.provider)
832 .replace("{date}", &not_listed.checked),
833 );
834 }
835 if let Some(pin) = pin {
836 chips.insert(
837 0,
838 pin.label.clone().unwrap_or_else(|| {
839 tr(self.locale, MessageId::ModelPickerPinnedChip).into_owned()
840 }),
841 );
842 }
843 chips
844 },
845 // Pinned rows sort above every group, so they draw no family
846 // header: otherwise the family's header repeats where its
847 // unpinned rows begin.
848 family: (grouped && pin.is_none())
849 .then(|| {
850 row.provider.and_then(|provider| {
851 catalog_family_for_identity(
852 provider,
853 row.provider_identity.as_deref(),
854 &row.id,
855 )
856 })
857 })
858 .flatten(),
859 active: row.id == self.initial_model
860 && model_row_matches_route(
861 row,
862 self.initial_provider,
863 &self.initial_provider_identity,
864 ),
865 locked: !row.selectable,
866 dim: row.not_listed.is_some(),
867 })
868 .collect();
869 let custom = self.custom_model_row_for_visible(&visible);
870 if let Some((model, provider)) = custom.as_ref() {
871 rows.push(PaneRow {
872 primary: model.clone(),
873 route: provider
874 .compatibility()
875 .map(|row| row.label)
876 .unwrap_or(provider.key.as_str())
877 .to_string(),
878 meta: vec![
879 if query.is_empty() {
880 "current (custom)"
881 } else {
882 "custom route"
883 }
884 .to_string(),
885 ],
886 ..PaneRow::default()
887 });
888 }
889 *self.projection.borrow_mut() = Some(ModelPickerProjection {
890 query: self.query.clone(),
891 view: self.view,
892 sort: self.sort,
893 indices,
894 rows,
895 custom,
896 });
897 }
898
899 fn visible_model_rows(&self) -> VisibleModelRows<'_> {
900 self.ensure_projection();
901 VisibleModelRows {
902 catalog: &self.model_rows,
903 indices: Ref::map(self.projection.borrow(), |projection| {
904 projection.as_ref().unwrap().indices.as_slice()
905 }),
906 }
907 }
908
909 fn model_row_count(&self) -> usize {
910 self.ensure_projection();
911 self.projection.borrow().as_ref().unwrap().rows.len()
912 }
913
914 /// Resolve the currently highlighted row to a model id.
915 fn resolved_model(&self) -> String {
916 let rows = self.visible_model_rows();
917 if self.selected_model_idx < rows.len() {
918 return rows[self.selected_model_idx].id.clone();
919 }
920 self.custom_model_row()
921 .map(|(model, _)| model)
922 .unwrap_or_else(|| self.initial_model.clone())
923 }
924
925 fn selected_model_is_selectable(&self) -> bool {
926 if matches!(
927 self.purpose,
928 ModelPickerPurpose::FleetRoute {
929 allow_inherit: false,
930 ..
931 }
932 ) && self.resolved_model() == "auto"
933 {
934 return false;
935 }
936 let rows = self.visible_model_rows();
937 if let Some(row) = rows.get(self.selected_model_idx) {
938 return row.selectable
939 && row.admitted_identity.as_ref().is_some_and(|identity| {
940 self.route_config.verify_provider_identity(identity).is_ok()
941 });
942 }
943 self.custom_model_row().is_some_and(|(model, provider)| {
944 crate::provider_readiness::resolve_for_model(
945 &self.route_config,
946 &provider,
947 &model,
948 &self.provider_health,
949 )
950 .can_attempt()
951 })
952 }
953
954 /// Feedback when Enter/apply is pressed on a locked (unauthenticated) model.
955 /// Surfaces the readiness reason instead of a silent no-op, and routes the
956 /// user toward provider authentication/setup when possible.
957 fn explain_unselectable_selection(&self) -> ViewAction {
958 let rows = self.visible_model_rows();
959 let Some(row) = rows.get(self.selected_model_idx) else {
960 return ViewAction::None;
961 };
962 let reason = if row.hint.trim().is_empty() {
963 "This model is not available with the current provider credentials.".to_string()
964 } else {
965 row.hint.clone()
966 };
967 let route = row.provider_identity.as_deref().unwrap_or(
968 row.provider
969 .map(ProviderKind::as_str)
970 .unwrap_or("unknown route"),
971 );
972 let message = format!(
973 "! {route}/{} is locked — {reason}. Open /provider and select {route} to authenticate, then refresh.",
974 row.id
975 );
976 // The ordinary setup wizard switches the session after auth. A
977 // Fleet edit must keep that session route intact.
978 if self.purpose != ModelPickerPurpose::Session {
979 return ViewAction::Emit(ViewEvent::StatusMessage { message });
980 }
981 // Prefer opening provider setup so the user can remediate in one step.
982 if let Some(identity) = row
983 .admitted_identity
984 .as_ref()
985 .filter(|identity| self.route_config.verify_provider_identity(identity).is_ok())
986 {
987 return ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth {
988 identity: identity.clone(),
989 model: row.id.clone(),
990 reason: message,
991 });
992 }
993 ViewAction::Emit(ViewEvent::StatusMessage { message })
994 }
995
996 /// Exact route identity of the highlighted row, when it names one.
997 #[cfg(test)]
998 fn resolved_provider_identity(&self) -> Option<String> {
999 self.resolved_admitted_identity()
1000 .map(|identity| identity.key.to_string())
1001 }
1002
1003 fn resolved_admitted_identity(&self) -> Option<crate::config::ProviderIdentity> {
1004 let rows = self.visible_model_rows();
1005 let identity = if let Some(row) = rows.get(self.selected_model_idx) {
1006 row.admitted_identity.clone()
1007 } else {
1008 self.custom_model_row().map(|(_, identity)| identity)
1009 }?;
1010 self.route_config.verify_provider_identity(&identity).ok()?;
1011 Some(identity)
1012 }
1013
1014 fn resolved_provider(&self) -> Option<ProviderKind> {
1015 let rows = self.visible_model_rows();
1016 if self.selected_model_idx < rows.len() {
1017 return rows[self.selected_model_idx].provider;
1018 }
1019 self.custom_model_row()
1020 .map(|(_, identity)| identity.provider)
1021 .or(Some(self.initial_provider))
1022 }
1023
1024 fn resolved_effort(&self) -> ReasoningEffort {
1025 let efforts = self.current_efforts();
1026 efforts[self
1027 .selected_effort_idx
1028 .min(efforts.len().saturating_sub(1))]
1029 }
1030
1031 fn current_efforts(&self) -> Vec<ReasoningEffort> {
1032 if matches!(
1033 self.purpose,
1034 ModelPickerPurpose::FleetRoute {
1035 allow_inherit: false,
1036 ..
1037 }
1038 ) {
1039 return vec![ReasoningEffort::Auto];
1040 }
1041 if let ModelPickerPurpose::FleetRoute {
1042 initial_reasoning, ..
1043 }
1044 | ModelPickerPurpose::FleetProfileRoute {
1045 initial_reasoning, ..
1046 } = self.purpose
1047 && self.resolved_model() == "auto"
1048 {
1049 return vec![initial_reasoning.unwrap_or(ReasoningEffort::Auto)];
1050 }
1051 let provider = self.resolved_provider().unwrap_or(self.initial_provider);
1052 let model = self.resolved_model();
1053 let base_url = self.resolved_base_url_for_provider(provider, &model);
1054 picker_efforts_for_route(
1055 provider,
1056 &base_url,
1057 &model,
1058 model.trim().eq_ignore_ascii_case("auto"),
1059 )
1060 }
1061
1062 fn resolved_base_url_for_provider(&self, _provider: ProviderKind, model: &str) -> String {
1063 let Some(identity) = self.resolved_admitted_identity() else {
1064 return String::new();
1065 };
1066 crate::route_runtime::resolve_runtime_route_for_identity(
1067 &self.route_config,
1068 &identity,
1069 Some(model),
1070 )
1071 .map(|route| route.candidate.endpoint().base_url.clone())
1072 .unwrap_or_else(|_| self.route_config.base_url_for_route(&identity))
1073 }
1074
1075 fn custom_model_row(&self) -> Option<(String, crate::config::ProviderIdentity)> {
1076 self.ensure_projection();
1077 self.projection.borrow().as_ref().unwrap().custom.clone()
1078 }
1079
1080 fn custom_model_row_for_visible(
1081 &self,
1082 visible_rows: &[&ModelPickerRow],
1083 ) -> Option<(String, crate::config::ProviderIdentity)> {
1084 let query = self.query.trim();
1085 if query.is_empty() {
1086 return self
1087 .show_custom_model_row
1088 .then(|| self.initial_identity.clone())
1089 .flatten()
1090 .map(|identity| (self.initial_model.clone(), identity));
1091 }
1092 if let Some((provider, model)) = self.provider_qualified_custom_query(query) {
1093 if visible_rows.iter().any(|row| {
1094 row.admitted_identity.as_ref() == Some(&provider)
1095 && row.id.eq_ignore_ascii_case(model.trim())
1096 }) {
1097 return None;
1098 }
1099 if self.provider_accepts_custom_model(&provider, &model) {
1100 return Some((model, provider));
1101 }
1102 return None;
1103 }
1104 if !self.active_accepts_custom_model_ids {
1105 return None;
1106 }
1107 if visible_rows.iter().any(|row| {
1108 row.admitted_identity.as_ref() == self.initial_identity.as_ref()
1109 && row.id.eq_ignore_ascii_case(query)
1110 }) {
1111 return None;
1112 }
1113 self.initial_identity
1114 .clone()
1115 .map(|identity| (query.to_string(), identity))
1116 }
1117
1118 fn provider_qualified_custom_query(
1119 &self,
1120 query: &str,
1121 ) -> Option<(crate::config::ProviderIdentity, String)> {
1122 for (provider_key, model) in provider_query_splits(query) {
1123 let Ok(provider) = self.route_config.legacy_selection_identity(provider_key) else {
1124 continue;
1125 };
1126 if Some(&provider) != self.initial_identity.as_ref()
1127 && !self.view.browses_all_providers()
1128 && !self.configured_providers.contains(&provider)
1129 {
1130 continue;
1131 }
1132 let model = model.trim();
1133 if model.is_empty() {
1134 continue;
1135 }
1136 return Some((provider, model.to_string()));
1137 }
1138 None
1139 }
1140
1141 fn provider_accepts_custom_model(
1142 &self,
1143 identity: &crate::config::ProviderIdentity,
1144 model: &str,
1145 ) -> bool {
1146 (Some(identity) == self.initial_identity.as_ref() && self.active_accepts_custom_model_ids)
1147 || (Some(identity) != self.initial_identity.as_ref()
1148 && self
1149 .route_config
1150 .model_ids_pass_through_for_provider(identity))
1151 || crate::config::normalize_model_name_for_provider(identity.provider, model).is_some()
1152 }
1153
1154 fn clamp_model_selection(&mut self) {
1155 let count = self.model_row_count();
1156 if count == 0 {
1157 self.selected_model_idx = 0;
1158 } else if self.selected_model_idx >= count {
1159 self.selected_model_idx = count - 1;
1160 }
1161 }
1162
1163 fn update_query(&mut self, next: String) {
1164 self.query = next;
1165 self.selected_model_idx = 0;
1166 self.clamp_model_selection();
1167 self.select_effort_for_current_model();
1168 }
1169
1170 fn select_effort_for_current_model(&mut self) {
1171 let provider = self.resolved_provider().unwrap_or(self.initial_provider);
1172 let model = self.resolved_model();
1173 let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
1174 let base_url = self.resolved_base_url_for_provider(provider, &model);
1175 let normalized = normalize_picker_effort(
1176 self.selected_effort_request,
1177 provider,
1178 &base_url,
1179 &model,
1180 model_is_auto,
1181 );
1182 self.selected_effort_idx =
1183 picker_efforts_for_route(provider, &base_url, &model, model_is_auto)
1184 .iter()
1185 .position(|candidate| *candidate == normalized)
1186 .unwrap_or_else(|| {
1187 default_picker_effort_idx(provider, &base_url, &model, model_is_auto)
1188 });
1189 }
1190
1191 /// Both panes rotate rather than stop at the ends. Thinking is four to six
1192 /// rows, so a hard stop at the bottom reads as a dead key rather than as a
1193 /// boundary; the model list wraps for the same reason.
1194 fn move_up(&mut self) -> bool {
1195 match self.focus {
1196 Pane::Model => {
1197 let count = self.model_row_count();
1198 if count == 0 {
1199 return false;
1200 }
1201 self.selected_model_idx = wrapping_prev(self.selected_model_idx, count);
1202 self.select_effort_for_current_model();
1203 true
1204 }
1205 Pane::Effort => {
1206 let count = self.current_efforts().len();
1207 if count == 0 {
1208 return false;
1209 }
1210 self.selected_effort_idx = wrapping_prev(self.selected_effort_idx, count);
1211 self.selected_effort_request = self.resolved_effort();
1212 true
1213 }
1214 }
1215 }
1216
1217 fn move_down(&mut self) -> bool {
1218 match self.focus {
1219 Pane::Model => {
1220 let count = self.model_row_count();
1221 if count == 0 {
1222 return false;
1223 }
1224 self.selected_model_idx = wrapping_next(self.selected_model_idx, count);
1225 self.select_effort_for_current_model();
1226 true
1227 }
1228 Pane::Effort => {
1229 let count = self.current_efforts().len();
1230 if count == 0 {
1231 return false;
1232 }
1233 self.selected_effort_idx = wrapping_next(self.selected_effort_idx, count);
1234 self.selected_effort_request = self.resolved_effort();
1235 true
1236 }
1237 }
1238 }
1239
1240 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
1241 /// whether it was consumed. Steps wrap; pages travel [`MODEL_PAGE`] rows
1242 /// and clamp. The region axis toggles between the model and effort panes.
1243 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
1244 use crate::tui::list_nav::Motion;
1245 if matches!(motion, Motion::RegionPrev | Motion::RegionNext) {
1246 if self.can_edit_effort() {
1247 self.toggle_focus();
1248 }
1249 return true;
1250 }
1251 let (current, len) = match self.focus {
1252 Pane::Model => (self.selected_model_idx, self.model_row_count()),
1253 Pane::Effort => (self.selected_effort_idx, self.current_efforts().len()),
1254 };
1255 if len == 0 {
1256 return false;
1257 }
1258 let Some(next) = crate::tui::list_nav::apply(current, len, MODEL_PAGE, motion) else {
1259 return false;
1260 };
1261 match self.focus {
1262 Pane::Model => {
1263 self.selected_model_idx = next;
1264 self.select_effort_for_current_model();
1265 }
1266 Pane::Effort => {
1267 self.selected_effort_idx = next;
1268 self.selected_effort_request = self.resolved_effort();
1269 }
1270 }
1271 true
1272 }
1273
1274 fn toggle_focus(&mut self) {
1275 self.focus = match self.focus {
1276 Pane::Model => Pane::Effort,
1277 Pane::Effort => Pane::Model,
1278 };
1279 }
1280
1281 fn toggle_view(&mut self) {
1282 self.view = self.view.next();
1283 self.selected_model_idx = 0;
1284 self.clamp_model_selection();
1285 self.select_effort_for_current_model();
1286 }
1287
1288 fn build_event_with_startup_default(&self, save_as_startup_default: bool) -> ViewEvent {
1289 ViewEvent::ModelPickerApplied {
1290 model: self.resolved_model(),
1291 identity: self.resolved_admitted_identity(),
1292 effort: self.selected_effort_request,
1293 previous_model: self.previous_model.clone(),
1294 previous_effort: self.initial_effort,
1295 save_as_startup_default,
1296 }
1297 }
1298
1299 /// The event Enter (or the startup-default chord) emits, by purpose. A
1300 /// Fleet row gets its absolute route — provider resolved, `Custom` named
1301 /// by its exact identity — and has no startup default to save.
1302 fn build_apply_event(&self, save_as_startup_default: bool) -> ViewEvent {
1303 let (initial_reasoning, allow_inherit) = match self.purpose {
1304 ModelPickerPurpose::Session => {
1305 return self.build_event_with_startup_default(save_as_startup_default);
1306 }
1307 ModelPickerPurpose::FleetRoute {
1308 initial_reasoning,
1309 allow_inherit,
1310 ..
1311 } => (initial_reasoning, allow_inherit),
1312 ModelPickerPurpose::FleetProfileRoute {
1313 initial_reasoning, ..
1314 } => (initial_reasoning, true),
1315 };
1316 let captured = self.resolved_admitted_identity();
1317 let provider = captured
1318 .as_ref()
1319 .map_or(self.initial_provider, |identity| identity.provider);
1320 let provider_id = captured.as_ref().map(|identity| identity.key.to_string());
1321 let model = self.resolved_model();
1322 let reasoning = if !allow_inherit {
1323 None
1324 } else if model == "auto" || self.selected_effort_request == self.initial_effort {
1325 initial_reasoning
1326 } else {
1327 Some(self.selected_effort_request)
1328 };
1329 match self.purpose {
1330 ModelPickerPurpose::FleetRoute {
1331 target, editor_id, ..
1332 } => ViewEvent::FleetRoutePicked {
1333 target,
1334 editor_id,
1335 provider,
1336 provider_id,
1337 model,
1338 reasoning,
1339 },
1340 ModelPickerPurpose::FleetProfileRoute { editor_id, .. } => {
1341 ViewEvent::FleetProfileRoutePicked {
1342 editor_id,
1343 provider,
1344 provider_id,
1345 model,
1346 reasoning,
1347 }
1348 }
1349 ModelPickerPurpose::Session => unreachable!("session handled above"),
1350 }
1351 }
1352
1353 /// Footer label for Enter: apply to the session, or assign to a Fleet row.
1354 fn apply_action_id(&self) -> MessageId {
1355 match self.purpose {
1356 ModelPickerPurpose::Session => MessageId::PickerActionApply,
1357 ModelPickerPurpose::FleetRoute { .. }
1358 | ModelPickerPurpose::FleetProfileRoute { .. } => MessageId::PickerActionAssignRoute,
1359 }
1360 }
1361
1362 fn can_edit_effort(&self) -> bool {
1363 match self.purpose {
1364 ModelPickerPurpose::Session => true,
1365 ModelPickerPurpose::FleetProfileRoute { .. } => self.resolved_model() != "auto",
1366 // Shortlisted rows pin a model only; inherited routes retain
1367 // their existing reasoning until a concrete model is selected.
1368 ModelPickerPurpose::FleetRoute { allow_inherit, .. } => {
1369 allow_inherit && self.resolved_model() != "auto"
1370 }
1371 }
1372 }
1373
1374 fn set_sort(&mut self, sort: Option<ModelSort>) {
1375 let selected = self
1376 .visible_model_rows()
1377 .indices
1378 .get(self.selected_model_idx)
1379 .copied();
1380 let was_custom = selected.is_none() && self.custom_model_row().is_some();
1381 self.sort = sort;
1382 self.last_mouse_selected = None;
1383 self.ensure_projection();
1384 if let Some(selected) = selected {
1385 let position = self
1386 .visible_model_rows()
1387 .indices
1388 .iter()
1389 .position(|index| *index == selected);
1390 if let Some(position) = position {
1391 self.selected_model_idx = position;
1392 }
1393 } else if was_custom {
1394 let visible_len = self.visible_model_rows().len();
1395 self.selected_model_idx = visible_len;
1396 }
1397 self.clamp_model_selection();
1398 self.select_effort_for_current_model();
1399 }
1400
1401 fn sort_column(&mut self, column: ModelSortColumn) {
1402 let descending = self
1403 .sort
1404 .is_some_and(|sort| sort.column == column && !sort.descending);
1405 self.set_sort(Some(ModelSort { column, descending }));
1406 }
1407
1408 fn cycle_sort(&mut self) {
1409 use ModelSortColumn::{Context, Model, Provider};
1410 let next = match self.sort {
1411 None => Some(ModelSort {
1412 column: Model,
1413 descending: false,
1414 }),
1415 Some(ModelSort {
1416 column,
1417 descending: false,
1418 }) => Some(ModelSort {
1419 column,
1420 descending: true,
1421 }),
1422 Some(ModelSort {
1423 column: Model,
1424 descending: true,
1425 }) => Some(ModelSort {
1426 column: Provider,
1427 descending: false,
1428 }),
1429 Some(ModelSort {
1430 column: Provider,
1431 descending: true,
1432 }) => Some(ModelSort {
1433 column: Context,
1434 descending: false,
1435 }),
1436 Some(ModelSort {
1437 column: Context,
1438 descending: true,
1439 }) => None,
1440 };
1441 self.set_sort(next);
1442 }
1443
1444 fn render_sort_columns(&self, area: Rect, buf: &mut Buffer, columns: ModelRowColumns) {
1445 let label = |name: &str, column| match self.sort.filter(|sort| sort.column == column) {
1446 Some(sort) => format!("{name} {}", if sort.descending { "↓" } else { "↑" }),
1447 None => name.to_string(),
1448 };
1449 let row = PaneRow {
1450 primary: label("Model", ModelSortColumn::Model),
1451 route: label("Provider", ModelSortColumn::Provider),
1452 meta: vec![label("Context", ModelSortColumn::Context)],
1453 ..PaneRow::default()
1454 };
1455 let style = Style::default().fg(palette::TEXT_MUTED).bold();
1456 Paragraph::new(Line::from(picker_row_spans(
1457 &row,
1458 " ",
1459 usize::from(area.width),
1460 columns,
1461 style,
1462 style,
1463 )))
1464 .render(area, buf);
1465 let fitted = columns.resolve(usize::from(area.width));
1466 let mut x = usize::from(area.x) + ROW_PREFIX_WIDTH;
1467 let right = usize::from(area.right());
1468 for (width, column) in [
1469 (fitted.primary, ModelSortColumn::Model),
1470 (fitted.route, ModelSortColumn::Provider),
1471 (fitted.meta, ModelSortColumn::Context),
1472 ] {
1473 if width > 0 {
1474 if x < right {
1475 self.column_hitboxes.borrow_mut().push((
1476 Rect::new(x as u16, area.y, width.min(right - x) as u16, 1),
1477 column,
1478 ));
1479 }
1480 x += width + COLUMN_GAP;
1481 }
1482 }
1483 }
1484
1485 fn render_pane(
1486 &self,
1487 area: Rect,
1488 buf: &mut Buffer,
1489 title: &str,
1490 rows: &[PaneRow],
1491 state: PaneRenderState,
1492 ) {
1493 self.pane_hitboxes.borrow_mut().push((area, state.pane));
1494 // A short stacked picker gives the focused pane the working space.
1495 // The other pane remains a clickable summary with its actual choice.
1496 if area.height == 1 && !state.focused {
1497 let summary = rows.get(state.selected).map_or_else(
1498 || title.to_string(),
1499 |row| format!("{title}: {}", row.primary),
1500 );
1501 Paragraph::new(crate::tui::ui_text::semantic_truncate(
1502 &summary,
1503 usize::from(area.width),
1504 ))
1505 .style(Style::default().fg(palette::TEXT_MUTED))
1506 .render(area, buf);
1507 if !rows.is_empty() {
1508 self.row_hitboxes
1509 .borrow_mut()
1510 .push((area, state.pane, state.selected));
1511 }
1512 return;
1513 }
1514 let header_height = if state.pane == Pane::Model && area.height >= 3 {
1515 2
1516 } else {
1517 1
1518 };
1519 let visible_height = usize::from(area.height.saturating_sub(header_height));
1520 let (start, end) = pane_row_window(state.selected, rows, visible_height);
1521 let title = if rows.len() > visible_height && visible_height > 0 {
1522 if start + 1 == end {
1523 // A scrollable pane whose visible window spans exactly one row
1524 // renders a single position (`Model 2/3`), not a degenerate
1525 // `2-2/3` range (#3995).
1526 format!(" {title} {}/{} ", end, rows.len())
1527 } else {
1528 format!(" {title} {}-{}/{} ", start + 1, end, rows.len())
1529 }
1530 } else {
1531 format!(" {title} ")
1532 };
1533 Block::default()
1534 .style(Style::default().bg(palette::WHALE_BG))
1535 .render(area, buf);
1536 let title_area = Rect { height: 1, ..area };
1537 Paragraph::new(Line::from(vec![
1538 Span::raw(" "),
1539 Span::styled(
1540 title,
1541 Style::default()
1542 .fg(if state.focused {
1543 palette::TEXT_PRIMARY
1544 } else {
1545 palette::TEXT_MUTED
1546 })
1547 .bold(),
1548 ),
1549 ]))
1550 .render(title_area, buf);
1551 let inner = Rect {
1552 y: area.y.saturating_add(header_height),
1553 height: area.height.saturating_sub(header_height),
1554 ..area
1555 };
1556
1557 // Column widths are measured over the rows actually on screen, so the
1558 // route column lands at one predictable offset for the whole page
1559 // instead of drifting with whatever long id happens to be scrolled in.
1560 let mut columns =
1561 ModelRowColumns::for_page(&rows[start.min(rows.len())..end.min(rows.len())]);
1562 if header_height == 2 {
1563 columns.primary = columns.primary.max(7);
1564 columns.route = columns.route.max(10);
1565 columns.meta = columns.meta.max(9);
1566 self.render_sort_columns(Rect::new(area.x, area.y + 1, area.width, 1), buf, columns);
1567 }
1568
1569 let mut lines = Vec::with_capacity(end.saturating_sub(start));
1570 let pane_height = usize::from(inner.height);
1571 for (idx, row) in rows.iter().enumerate().skip(start).take(end - start) {
1572 // Family headers consume pane lines too: stop building (and stop
1573 // recording hitboxes) as soon as the pane is full, so rendering
1574 // never addresses the buffer past its bounds.
1575 if lines.len() >= pane_height {
1576 break;
1577 }
1578 let is_selected = idx == state.selected;
1579 // Only the focused pane owns the keyboard cursor. Unavailable
1580 // routes retain a width-safe attention mark and warning ink.
1581 let locked = row.locked;
1582 let focused = is_selected && state.focused;
1583 let marker = if focused {
1584 crate::tui::glyphs::SELECTION
1585 } else if locked {
1586 crate::tui::glyphs::ATTENTION
1587 } else if row.active {
1588 crate::tui::glyphs::CURRENT
1589 } else {
1590 " "
1591 };
1592 let hovered = self.hovered_row == Some((state.pane, idx)) && !focused;
1593 let label_style = if focused {
1594 menu_style::selected_row_style()
1595 } else if hovered {
1596 menu_style::hovered_row_style().fg(if locked {
1597 palette::TEXT_MUTED
1598 } else {
1599 palette::TEXT_PRIMARY
1600 })
1601 } else if is_selected {
1602 Style::default()
1603 .fg(palette::TEXT_MUTED)
1604 .bg(palette::SURFACE_ELEVATED)
1605 } else if locked || row.dim {
1606 Style::default()
1607 .fg(palette::TEXT_MUTED)
1608 .add_modifier(Modifier::DIM)
1609 } else {
1610 Style::default().fg(palette::TEXT_PRIMARY)
1611 };
1612 let hint_style = if focused {
1613 menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
1614 } else if locked || row.dim {
1615 label_style.fg(palette::TEXT_MUTED)
1616 } else if hovered {
1617 menu_style::hovered_row_style().fg(palette::TEXT_MUTED)
1618 } else {
1619 Style::default().fg(palette::TEXT_MUTED)
1620 };
1621 // Provider → family → model grouping: a dim family header is
1622 // drawn when the catalog states a family and it differs from the
1623 // previous visible row's (families sort contiguously). Unknown
1624 // families draw nothing.
1625 if family_header_before(rows, idx) && pane_height > 1 {
1626 lines.push(Line::from(Span::styled(
1627 format!(" ─ {}", row.family.as_deref().unwrap_or_default()),
1628 Style::default().fg(palette::TEXT_DIM),
1629 )));
1630 }
1631 // The hitbox points at the row's own line (after any family
1632 // header), so mouse/scan targets and keyboard targets agree.
1633 let row_y = inner.y.saturating_add(lines.len() as u16);
1634 self.row_hitboxes.borrow_mut().push((
1635 Rect::new(inner.x, row_y, inner.width, 1),
1636 state.pane,
1637 idx,
1638 ));
1639 if focused || hovered {
1640 buf.set_style(Rect::new(inner.x, row_y, inner.width, 1), label_style);
1641 }
1642 let spans = picker_row_spans(
1643 row,
1644 marker,
1645 usize::from(inner.width),
1646 columns,
1647 label_style,
1648 hint_style,
1649 );
1650 lines.push(Line::from(spans).style(if focused || hovered {
1651 label_style
1652 } else {
1653 Style::default()
1654 }));
1655 }
1656 if rows.is_empty() {
1657 // A search that matches nothing must say so, not render a bare
1658 // empty box (#3757 UX review).
1659 let message = if self.query.is_empty() {
1660 tr(self.locale, MessageId::RouteNoModels).into_owned()
1661 } else {
1662 tr(self.locale, MessageId::RouteNoModelMatch).replace("{query}", &self.query)
1663 };
1664 lines.push(Line::from(Span::styled(
1665 message,
1666 Style::default().fg(palette::TEXT_MUTED),
1667 )));
1668 }
1669 // Family headers can push the visible rows past the viewport; clip
1670 // to the area so rendering never indexes the buffer out of bounds
1671 // (ratatui-core 0.1.0 panics instead of clipping).
1672 if lines.len() > usize::from(inner.height) {
1673 lines.truncate(usize::from(inner.height));
1674 }
1675 Paragraph::new(lines).render(inner, buf);
1676 }
1677 }
1678
1679 fn family_header_before(rows: &[PaneRow], index: usize) -> bool {
1680 let row = &rows[index];
1681 row.family.as_deref().is_some_and(|family| {
1682 rows.get(index.wrapping_sub(1)).is_none_or(|previous| {
1683 previous.family.as_deref() != Some(family) || previous.route != row.route
1684 })
1685 })
1686 }
1687
1688 fn pane_row_window(selected: usize, rows: &[PaneRow], height: usize) -> (usize, usize) {
1689 if rows.is_empty() || height == 0 {
1690 return (0, 0);
1691 }
1692 let selected = selected.min(rows.len() - 1);
1693 let cost = |index| 1 + usize::from(height > 1 && family_header_before(rows, index));
1694 // Keep the selection near the middle, counting actual painted lines.
1695 let mut start = selected;
1696 let mut above = 0;
1697 while start > 0 && above + cost(start - 1) <= height.saturating_sub(cost(selected)) / 2 {
1698 start -= 1;
1699 above += cost(start);
1700 }
1701 let mut end = start;
1702 let mut used = 0;
1703 while end < rows.len() && used + cost(end) <= height {
1704 used += cost(end);
1705 end += 1;
1706 }
1707 // Fill the space above when we reach the end of the list.
1708 while start > 0 && used + cost(start - 1) <= height {
1709 start -= 1;
1710 used += cost(start);
1711 }
1712 (start, end)
1713 }
1714
1715 /// Widest Thinking row plus its marker: `max (extra-high reasoning)`.
1716 const EFFORT_PANE_WIDTH: u16 = 30;
1717
1718 /// Give the model list the width the Thinking pane cannot use.
1719 ///
1720 /// The generic list/detail split caps the list at 52 columns and hands the
1721 /// remainder to the detail pane. Thinking rows are a fixed, short vocabulary,
1722 /// so on a wide terminal most of the row went to a pane with nothing to put
1723 /// there while the model rows — which carry the id, route and metadata that
1724 /// tell near-identical routes apart — were squeezed into half a screen.
1725 fn widen_model_pane(layout: ListDetailLayout) -> ListDetailLayout {
1726 if layout.stacked {
1727 return layout;
1728 }
1729 let gap = layout
1730 .detail
1731 .x
1732 .saturating_sub(layout.list.x.saturating_add(layout.list.width));
1733 let total = layout.list.width + gap + layout.detail.width;
1734 let detail_width = layout.detail.width.min(EFFORT_PANE_WIDTH);
1735 let list_width = total.saturating_sub(gap + detail_width);
1736 ListDetailLayout {
1737 list: Rect {
1738 width: list_width,
1739 ..layout.list
1740 },
1741 detail: Rect {
1742 x: layout.list.x + list_width + gap,
1743 width: detail_width,
1744 ..layout.detail
1745 },
1746 stacked: false,
1747 }
1748 }
1749
1750 /// One rendered row in either picker pane, split into the columns the row is
1751 /// laid out from.
1752 ///
1753 /// Model rows fill all three: the wire id (`primary`), the route identity that
1754 /// separates same-named models on different endpoints (`route`), and the facts
1755 /// that actually vary between neighbouring rows (`meta`). Thinking-effort rows
1756 /// leave `route` empty and keep their descriptive `meta`.
1757 #[derive(Debug, Clone, Default, PartialEq, Eq)]
1758 struct PaneRow {
1759 primary: String,
1760 route: String,
1761 /// Metadata as separable units. Kept as a list so a squeezed column sheds
1762 /// whole facts instead of rendering half a word.
1763 meta: Vec<String>,
1764 /// Catalog model family (e.g. `deepseek`, `glm`) for section headers.
1765 /// None = the catalog did not state a family (no header is drawn).
1766 family: Option<String>,
1767 /// The route this session is already on.
1768 active: bool,
1769 locked: bool,
1770 /// Muted ink for a selectable row with a caveat (not listed by a fresh
1771 /// roster); unlike `locked` it keeps the ordinary marker.
1772 dim: bool,
1773 }
1774
1775 impl PaneRow {
1776 fn effort(primary: String, meta: String) -> Self {
1777 Self {
1778 primary,
1779 route: String::new(),
1780 meta: if meta.is_empty() {
1781 Vec::new()
1782 } else {
1783 vec![meta]
1784 },
1785 family: None,
1786 active: false,
1787 locked: false,
1788 dim: false,
1789 }
1790 }
1791
1792 fn meta_width(&self) -> usize {
1793 self.meta
1794 .iter()
1795 .map(|chip| unicode_width::UnicodeWidthStr::width(chip.as_str()))
1796 .sum::<usize>()
1797 + self.meta.len().saturating_sub(1) * 3
1798 }
1799 }
1800
1801 /// Per-page column offsets for a picker pane.
1802 ///
1803 /// Rows used to render as `label (one long parenthesised hint)`, which meant
1804 /// the hint was dropped whole whenever it did not fit — and at every real
1805 /// terminal width it never fit, so a dozen DeepSeek routes all rendered as
1806 /// nothing but their near-identical ids. Fixed columns fix that: each field
1807 /// gets a measured share of the row and is truncated on its own, so the
1808 /// distinguishing token is always on screen at a predictable offset.
1809 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1810 struct ModelRowColumns {
1811 primary: usize,
1812 route: usize,
1813 meta: usize,
1814 }
1815
1816 /// Width reserved for the marker glyph itself. The lock is a two-column emoji
1817 /// while `▸` and `●` are one, so the cell is padded to the widest of them —
1818 /// otherwise a single locked row shifts every column on its line by one.
1819 const MARKER_CELL_WIDTH: usize = 2;
1820 /// ` ▸ ` — one leading space, the marker cell, one trailing space.
1821 const ROW_PREFIX_WIDTH: usize = MARKER_CELL_WIDTH + 2;
1822 /// Blank cells between two columns.
1823 const COLUMN_GAP: usize = 2;
1824 /// Below this a route column tells the user nothing, so the space goes to the
1825 /// id instead.
1826 const MIN_ROUTE_WIDTH: usize = 6;
1827 /// Below this the metadata column cannot hold even a context-window token.
1828 const MIN_META_WIDTH: usize = 4;
1829
1830 impl ModelRowColumns {
1831 /// Measure the natural width each column wants, over the rows on screen.
1832 fn for_page(rows: &[PaneRow]) -> Self {
1833 let widest = |pick: fn(&PaneRow) -> usize| rows.iter().map(pick).max().unwrap_or(0);
1834 Self {
1835 primary: widest(|row| unicode_width::UnicodeWidthStr::width(row.primary.as_str())),
1836 route: widest(|row| unicode_width::UnicodeWidthStr::width(row.route.as_str())),
1837 meta: widest(PaneRow::meta_width),
1838 }
1839 }
1840
1841 /// Fit the measured widths into the width actually available.
1842 ///
1843 /// When everything fits, every column keeps its natural width. When it does
1844 /// not, the scarce space is divided rather than handed to whichever column
1845 /// comes first: the id used to take everything and the metadata was dropped
1846 /// whole, which is precisely how a dozen near-identical routes ended up
1847 /// rendering as nothing but their shared prefix.
1848 fn resolve(self, width: usize) -> Self {
1849 let available = width.saturating_sub(ROW_PREFIX_WIDTH);
1850 if available == 0 {
1851 return Self::default();
1852 }
1853 let gaps = COLUMN_GAP * (usize::from(self.route > 0) + usize::from(self.meta > 0));
1854 let content = available.saturating_sub(gaps);
1855 if content == 0 {
1856 return Self {
1857 primary: available,
1858 route: 0,
1859 meta: 0,
1860 };
1861 }
1862 if self.primary + self.route + self.meta <= content {
1863 return self;
1864 }
1865 // With no route column there is nothing to protect from a long id, so
1866 // the id keeps its natural width and the trailing metadata yields — a
1867 // clipped model id is worse than a hidden hint.
1868 if self.route == 0 {
1869 let primary = self.primary.min(content);
1870 let meta = self.meta.min(content.saturating_sub(primary));
1871 return Self {
1872 primary,
1873 route: 0,
1874 meta: if meta < MIN_META_WIDTH { 0 } else { meta },
1875 };
1876 }
1877
1878 // Floors first, so no column that has something to say disappears
1879 // entirely; then each takes the smaller of its natural width and its
1880 // share. Metadata is the densest per column and gets the tightest cap.
1881 let mut meta = if self.meta == 0 {
1882 0
1883 } else {
1884 self.meta
1885 .min((content / 4).max(MIN_META_WIDTH.min(content)))
1886 };
1887 let after_meta = content.saturating_sub(meta);
1888 let mut route = if self.route == 0 {
1889 0
1890 } else {
1891 self.route
1892 .min((after_meta / 3).max(MIN_ROUTE_WIDTH.min(after_meta)))
1893 };
1894 let mut primary = after_meta.saturating_sub(route);
1895
1896 // The id's share is whatever the other two did not take, which can
1897 // exceed the longest id on the page. Hand that surplus back rather than
1898 // padding blank space next to a metadata column that is shedding facts.
1899 if primary > self.primary {
1900 let mut slack = primary - self.primary;
1901 primary = self.primary;
1902 for (column, natural) in [(&mut meta, self.meta), (&mut route, self.route)] {
1903 let gain = slack.min(natural.saturating_sub(*column));
1904 *column += gain;
1905 slack -= gain;
1906 }
1907 primary += slack;
1908 }
1909
1910 Self {
1911 primary,
1912 route,
1913 meta,
1914 }
1915 }
1916 }
1917
1918 /// Truncate an identifier from the middle, keeping both ends.
1919 ///
1920 /// Model ids and route names share their heads and differ in their tails:
1921 /// `deepseek-ai/DeepSeek-V4-Pro` and `deepseek-ai/DeepSeek-V4-Flash` are
1922 /// identical for twenty characters and only separate at the very end. Clipping
1923 /// the tail therefore deletes the one token that tells them apart — both rows
1924 /// render as `deepseek-ai/DeepSee...`. Keeping a slice of each end costs one
1925 /// column for the ellipsis and preserves the variant.
1926 fn fit_identifier(text: &str, width: usize) -> String {
1927 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1928
1929 if UnicodeWidthStr::width(text) <= width {
1930 return text.to_string();
1931 }
1932 // Too narrow to seat a head, an ellipsis and a meaningful tail; fall back
1933 // to the plain head-first form rather than emit punctuation soup.
1934 if width < 8 {
1935 return fit_text(text, width);
1936 }
1937
1938 let budget = width - 1;
1939 // The tail is the discriminating end, so it gets the larger share.
1940 let tail_budget = (budget * 3) / 5;
1941 let head_budget = budget - tail_budget;
1942
1943 let mut head = String::new();
1944 let mut used = 0usize;
1945 for ch in text.chars() {
1946 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1947 if used + ch_width > head_budget {
1948 break;
1949 }
1950 used += ch_width;
1951 head.push(ch);
1952 }
1953
1954 let mut tail: Vec<char> = Vec::new();
1955 let mut used = 0usize;
1956 for ch in text.chars().rev() {
1957 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1958 if used + ch_width > tail_budget {
1959 break;
1960 }
1961 used += ch_width;
1962 tail.push(ch);
1963 }
1964 tail.reverse();
1965
1966 let mut out = head;
1967 out.push('…');
1968 out.extend(tail);
1969 out
1970 }
1971
1972 /// Lay a row out into aligned, individually-truncated columns.
1973 ///
1974 /// Primary and secondary ink follow the pane's focus; unavailable routes
1975 /// retain a semantic warning mark independently of selection.
1976 fn picker_row_spans<'a>(
1977 row: &'a PaneRow,
1978 marker: &'static str,
1979 width: usize,
1980 columns: ModelRowColumns,
1981 label_style: Style,
1982 hint_style: Style,
1983 ) -> Vec<Span<'a>> {
1984 use unicode_width::UnicodeWidthStr;
1985
1986 let columns = columns.resolve(width);
1987 let marker_pad = MARKER_CELL_WIDTH.saturating_sub(UnicodeWidthStr::width(marker));
1988 let mut spans = vec![
1989 Span::styled(" ", label_style),
1990 Span::styled(
1991 marker,
1992 if row.locked {
1993 label_style.fg(palette::STATUS_WARNING)
1994 } else {
1995 label_style
1996 },
1997 ),
1998 Span::styled(" ".repeat(marker_pad + 1), label_style),
1999 ];
2000 let mut used = ROW_PREFIX_WIDTH;
2001
2002 let primary = fit_identifier(&row.primary, columns.primary.max(1));
2003 used += UnicodeWidthStr::width(primary.as_str());
2004 spans.push(Span::styled(primary, label_style));
2005
2006 // Pad to the column edge only when something follows; a trailing run of
2007 // spaces would otherwise extend the selected row's highlight past its text.
2008 let pad_to = |spans: &mut Vec<Span<'a>>, used: &mut usize, target: usize| {
2009 if *used < target {
2010 spans.push(Span::styled(" ".repeat(target - *used), label_style));
2011 *used = target;
2012 }
2013 };
2014
2015 if columns.route > 0 && !row.route.is_empty() {
2016 pad_to(&mut spans, &mut used, ROW_PREFIX_WIDTH + columns.primary);
2017 spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
2018 used += COLUMN_GAP;
2019 let route = fit_identifier(&row.route, columns.route);
2020 used += UnicodeWidthStr::width(route.as_str());
2021 spans.push(Span::styled(route, hint_style));
2022 }
2023
2024 if !row.meta.is_empty() {
2025 let column_edge = if columns.route > 0 && !row.route.is_empty() {
2026 ROW_PREFIX_WIDTH + columns.primary + COLUMN_GAP + columns.route
2027 } else {
2028 ROW_PREFIX_WIDTH + columns.primary
2029 };
2030 // Take the smaller of the column's share and the physical remainder, so
2031 // a row that ended early cannot overrun the pane.
2032 let remaining = width
2033 .saturating_sub(column_edge)
2034 .saturating_sub(COLUMN_GAP)
2035 .min(columns.meta.max(MIN_META_WIDTH));
2036 let meta = fit_meta_chips(&row.meta, remaining);
2037 if !meta.is_empty() {
2038 pad_to(&mut spans, &mut used, column_edge);
2039 spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
2040 spans.push(Span::styled(meta, hint_style));
2041 }
2042 }
2043
2044 spans
2045 }
2046
2047 fn fit_text(text: &str, width: usize) -> String {
2048 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
2049
2050 if UnicodeWidthStr::width(text) <= width {
2051 return text.to_string();
2052 }
2053 if width == 0 {
2054 return String::new();
2055 }
2056 if width <= 3 {
2057 return ".".repeat(width);
2058 }
2059
2060 let mut out = String::new();
2061 let target = width - 3;
2062 let mut used = 0usize;
2063 for ch in text.chars() {
2064 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
2065 if used + ch_width > target {
2066 break;
2067 }
2068 used += ch_width;
2069 out.push(ch);
2070 }
2071 out.push_str("...");
2072 out
2073 }
2074
2075 pub(crate) fn provider_scoped_model_completion_ids(app: &App) -> Vec<String> {
2076 // Slash completions inline the current custom model so `/model <current>`
2077 // stays visible even when it is outside the provider catalog.
2078 provider_scoped_model_ids_for_app(app, true)
2079 }
2080
2081 /// The pin (Fleet model or the person's own) naming this exact row, if any.
2082 fn pin_for_row<'a>(pins: &'a [PinnedModel], row: &ModelPickerRow) -> Option<&'a PinnedModel> {
2083 pins.iter().find(|pin| pin_names_row(pin, row))
2084 }
2085
2086 /// One route match for sorting, marking and labelling alike. Built-in
2087 /// provider slugs are case-insensitive aliases; a named custom provider's
2088 /// identity is its exact config key, so `TeamA` and `teama` stay two routes.
2089 /// Model ids are exact.
2090 fn pin_names_row(pin: &PinnedModel, row: &ModelPickerRow) -> bool {
2091 let named_custom = row.provider == Some(ProviderKind::Custom);
2092 row_provider_identity(row).is_some_and(|provider| {
2093 if named_custom {
2094 provider == pin.provider
2095 } else {
2096 provider.eq_ignore_ascii_case(&pin.provider)
2097 }
2098 }) && row.id == pin.model
2099 }
2100
2101 /// The pins the picker sorts and labels by: the fleet's models first (the
2102 /// selected Fleet's operator and every pinned member, labelled with the roles
2103 /// each fills — design §10 F1), then the person's own pins.
2104 fn picker_pins_for_app(app: &App) -> Vec<PinnedModel> {
2105 // A selected fleet that cannot be read contributes no pins; ⇧F on any
2106 // row then surfaces that store error instead of writing past it.
2107 crate::fleet::members::fleet_models(&app.workspace)
2108 .unwrap_or_default()
2109 .into_iter()
2110 .map(|member| PinnedModel {
2111 provider: member.provider.clone(),
2112 model: member.model.clone(),
2113 label: Some(format!("fleet · {}", member.roles_label())),
2114 })
2115 .chain(app.pinned_models.iter().cloned())
2116 .collect()
2117 }
2118
2119 fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow> {
2120 let mut rows = Vec::new();
2121 let auto_hint = auto_picker_hint(app, config);
2122 push_auto_model_row(&mut rows, app, config, &auto_hint);
2123 // One snapshot supplies both IDs, capabilities, and freshness so a cache
2124 // replacement cannot produce mixed-generation picker rows.
2125 let codex_roster = codex_model_cache::model_roster_for(config);
2126 let mut active_model_ids = if app.api_provider == ProviderKind::OpenaiCodex {
2127 let mut models = vec!["auto".to_string()];
2128 for id in codex_roster.model_ids() {
2129 push_model_id(&mut models, &id);
2130 }
2131 if let Some(model) = app
2132 .provider_models
2133 .get(app.provider_identity_for_persistence())
2134 .map(|model| model.trim())
2135 .filter(|model| !model.is_empty())
2136 {
2137 push_model_id(
2138 &mut models,
2139 picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
2140 );
2141 }
2142 models
2143 } else {
2144 provider_scoped_model_ids_for_app(app, false)
2145 };
2146 push_recent_model_ids(
2147 &mut active_model_ids,
2148 app,
2149 app.provider_identity_for_persistence(),
2150 );
2151 push_configured_provider_model(
2152 &mut active_model_ids,
2153 config,
2154 app.api_provider,
2155 app.provider_identity_for_persistence(),
2156 );
2157 if let Ok(identity) = app.admitted_provider_identity() {
2158 push_provider_model_rows(
2159 &mut rows,
2160 identity,
2161 active_model_ids,
2162 app.api_provider,
2163 config,
2164 &codex_roster,
2165 &app.provider_health,
2166 );
2167 }
2168
2169 for identity in config.provider_identities() {
2170 if app.admitted_provider_identity().ok() == Some(&identity) {
2171 continue;
2172 }
2173 push_inactive_route_rows(&mut rows, app, config, &identity, &codex_roster);
2174 }
2175
2176 // The fleet comes first (design §10 F1): every model the person added
2177 // to the selected Fleet rides the pin machinery ahead of their own pins,
2178 // labelled with the roles it fills, so the list leads with what they
2179 // chose rather than with a provider's alphabet.
2180 let pins = picker_pins_for_app(app);
2181 for row in &mut rows {
2182 if let Some(pin) = pin_for_row(&pins, row) {
2183 let pinned = tr(app.ui_locale, MessageId::ModelPickerPinnedChip);
2184 let label = pin.label.as_deref().unwrap_or(&pinned);
2185 row.hint = format!(
2186 "{label} · exact {} / {} · {}",
2187 pin.provider, pin.model, row.hint
2188 );
2189 }
2190 }
2191
2192 for pin in &pins {
2193 let provider = ProviderKind::parse(&pin.provider).unwrap_or(ProviderKind::Custom);
2194 if rows.iter().any(|row| {
2195 row_provider_identity(row).is_some_and(|identity| identity == pin.provider)
2196 && row.id == pin.model
2197 }) {
2198 continue;
2199 }
2200 let metadata = effective_picker_metadata_for_identity(
2201 config,
2202 Some(provider),
2203 Some(&pin.provider),
2204 &pin.model,
2205 );
2206 // Bypass the ordinary `(enum provider, model)` de-duplication here:
2207 // two named Custom routes may intentionally expose the same model id.
2208 rows.push(ModelPickerRow {
2209 id: pin.model.clone(),
2210 provider: Some(provider),
2211 provider_identity: Some(pin.provider.clone()),
2212 admitted_identity: None,
2213 hint: format!(
2214 "stale pinned · exact {} / {} · unavailable; repair or remove",
2215 pin.provider, pin.model
2216 ),
2217 metadata,
2218 selectable: false,
2219 blocked_reason: Some("stale pin".to_string()),
2220 default_rank: None,
2221 credentialed: false,
2222 not_listed: None,
2223 });
2224 }
2225
2226 let current = (!app.auto_model).then(|| {
2227 (
2228 app.provider_identity_for_persistence(),
2229 picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids()),
2230 )
2231 });
2232 assign_default_sections(&mut rows, app, config, current, &pins);
2233 rows
2234 }
2235
2236 /// Place rows into the default view's sections (#6533): the current route,
2237 /// then pins and Fleet models, then up to [`RECENT_ROW_LIMIT`] recently used
2238 /// routes by decayed use, then one default per credentialed route. A leftover
2239 /// `[providers.X].model` is that default only when the route is current or
2240 /// recently used; an unused route offers its built-in default instead, so a
2241 /// test model saved months ago no longer rides along. User-declared
2242 /// `[[models]]` rows keep their place. Everything else stays behind search.
2243 fn assign_default_sections(
2244 rows: &mut [ModelPickerRow],
2245 app: &App,
2246 config: &Config,
2247 current: Option<(&str, &str)>,
2248 pins: &[PinnedModel],
2249 ) {
2250 let now = chrono::Utc::now();
2251 let guard = app.route_usage.read().ok();
2252 let empty = crate::model_relevance::RouteUsageIndex::default();
2253 let usage = guard.as_deref().unwrap_or(&empty);
2254 let find = |rows: &[ModelPickerRow], identity: &str, model: &str| {
2255 rows.iter().position(|row| {
2256 row_provider_identity(row) == Some(identity) && row.id.eq_ignore_ascii_case(model)
2257 })
2258 };
2259 for row in rows.iter_mut() {
2260 row.default_rank = None;
2261 // `auto` is the current route when auto routing is on; otherwise it
2262 // follows the pins, ahead of recent use.
2263 if row.provider.is_none() {
2264 row.default_rank = Some(if current.is_none() {
2265 (DefaultSection::Current, 0)
2266 } else {
2267 (DefaultSection::Pinned, usize::MAX)
2268 });
2269 }
2270 }
2271 if let Some((identity, model)) = current
2272 && let Some(index) = find(rows, identity, model)
2273 {
2274 rows[index].default_rank = Some((DefaultSection::Current, 0));
2275 }
2276 for (rank, pin) in pins.iter().enumerate() {
2277 // Exact, as the pin labels match: a pin names one wire spelling.
2278 if let Some(row) = rows.iter_mut().find(|row| {
2279 row_provider_identity(row) == Some(pin.provider.as_str()) && row.id == pin.model
2280 }) {
2281 row.default_rank
2282 .get_or_insert((DefaultSection::Pinned, rank));
2283 }
2284 }
2285 let mut recent = 0;
2286 for route in usage.ranked(now) {
2287 if recent >= RECENT_ROW_LIMIT {
2288 break;
2289 }
2290 if let Some(index) = find(rows, &route.identity, &route.model)
2291 && rows[index].default_rank.is_none()
2292 {
2293 rows[index].default_rank = Some((DefaultSection::Recent, recent));
2294 recent += 1;
2295 }
2296 }
2297 let mut routes: Vec<(String, ProviderKind)> = Vec::new();
2298 for row in rows.iter() {
2299 if let (Some(provider), Some(identity)) = (row.provider, row_provider_identity(row))
2300 && !routes.iter().any(|(seen, _)| seen == identity)
2301 {
2302 routes.push((identity.to_string(), provider));
2303 }
2304 }
2305 for (order, (identity, provider)) in routes.iter().enumerate() {
2306 let Ok(admitted) = config.legacy_selection_identity(identity) else {
2307 continue;
2308 };
2309 if admitted.provider != *provider {
2310 continue;
2311 }
2312 let in_use = current.is_some_and(|(current, _)| current == identity)
2313 || usage.identity_used(identity, now);
2314 let configured = || {
2315 app.provider_models
2316 .get(identity)
2317 .map(String::as_str)
2318 .or_else(|| {
2319 route_provider_config(config, *provider, identity)
2320 .and_then(|entry| entry.model.as_deref())
2321 })
2322 .map(str::to_string)
2323 };
2324 let default = if in_use || *provider == ProviderKind::Custom {
2325 configured()
2326 } else {
2327 codewhale_config::descriptors::compatibility_for_id(identity)
2328 .filter(|row| row.kind == *provider)
2329 .map(|row| row.default_model.to_string())
2330 };
2331 let Some(default) = default
2332 .as_deref()
2333 .map(str::trim)
2334 .filter(|model| !model.is_empty() && !model.eq_ignore_ascii_case("auto"))
2335 .map(|model| {
2336 picker_visible_model_id(
2337 *provider,
2338 model,
2339 config.model_ids_pass_through_for_provider(&admitted),
2340 )
2341 .to_string()
2342 })
2343 else {
2344 continue;
2345 };
2346 if let Some(index) = find(rows, identity, &default)
2347 && rows[index].default_rank.is_none()
2348 && rows[index].credentialed
2349 {
2350 rows[index].default_rank = Some((DefaultSection::ProviderDefault, order));
2351 }
2352 }
2353 for row in rows.iter_mut() {
2354 if row.default_rank.is_none()
2355 && matches!(row.metadata.source, Some(CatalogSource::ConfigOverride))
2356 {
2357 row.default_rank = Some((DefaultSection::ProviderDefault, usize::MAX));
2358 }
2359 }
2360 }
2361
2362 /// Model ids this exact route was used with recently, so a model picked
2363 /// through `--model` or another client still has a row to rank.
2364 fn push_recent_model_ids(models: &mut Vec<String>, app: &App, identity: &str) {
2365 if let Ok(usage) = app.route_usage.read() {
2366 for model in usage.models_for_identity(identity, chrono::Utc::now()) {
2367 push_model_id(models, &model);
2368 }
2369 }
2370 }
2371
2372 /// Rows for one route that is not the session's active route.
2373 ///
2374 /// `identity` is the exact persistence key — the `[providers.<name>]` table
2375 /// for a named custom route, the provider slug otherwise — and every lookup
2376 /// here is made against it, so two routes that expose the same model id keep
2377 /// separate rows, separate remembered choices, and separate enablement.
2378 fn push_inactive_route_rows(
2379 rows: &mut Vec<ModelPickerRow>,
2380 app: &App,
2381 config: &Config,
2382 admitted: &crate::config::ProviderIdentity,
2383 codex_roster: &CodexModelRoster,
2384 ) {
2385 if config.verify_provider_identity(admitted).is_err() {
2386 return;
2387 }
2388 let provider = admitted.provider;
2389 let identity = admitted.key.as_str();
2390 let mut model_ids = if provider == ProviderKind::OpenaiCodex {
2391 codex_roster.model_ids()
2392 } else {
2393 provider_catalog_model_ids(provider, identity, &config.base_url_for_route(admitted))
2394 };
2395 if let Some(model) = app
2396 .provider_models
2397 .get(identity)
2398 .map(|model| model.trim())
2399 .filter(|model| !model.is_empty())
2400 {
2401 push_model_id(
2402 &mut model_ids,
2403 picker_visible_model_id(
2404 provider,
2405 model,
2406 config.model_ids_pass_through_for_provider(admitted),
2407 ),
2408 );
2409 }
2410 push_recent_model_ids(&mut model_ids, app, identity);
2411 push_configured_provider_model(&mut model_ids, config, provider, identity);
2412 push_provider_model_rows(
2413 rows,
2414 admitted,
2415 model_ids,
2416 app.api_provider,
2417 config,
2418 codex_roster,
2419 &app.provider_health,
2420 );
2421 }
2422
2423 /// The `[providers.…]` table that owns this exact route.
2424 ///
2425 /// [`Config::provider_config_for`] resolves `Custom` through the *selected*
2426 /// `provider = "<name>"`, which cannot describe a custom route the session is
2427 /// not on — and must never answer for one (#6016).
2428 fn route_provider_config<'a>(
2429 config: &'a Config,
2430 provider: ProviderKind,
2431 key: &str,
2432 ) -> Option<&'a crate::config::ProviderConfig> {
2433 let identity = config.legacy_selection_identity(key).ok()?;
2434 (identity.provider == provider).then_some(())?;
2435 config.provider_config_for(&identity)
2436 }
2437
2438 fn push_provider_model_rows(
2439 rows: &mut Vec<ModelPickerRow>,
2440 admitted: &crate::config::ProviderIdentity,
2441 mut model_ids: Vec<String>,
2442 active_provider: ProviderKind,
2443 config: &Config,
2444 codex_roster: &CodexModelRoster,
2445 provider_health: &crate::provider_readiness::ProviderReadinessSnapshot,
2446 ) {
2447 if config.verify_provider_identity(admitted).is_err() {
2448 return;
2449 }
2450 let provider = admitted.provider;
2451 let identity = admitted.key.to_string();
2452 let base_url = config.base_url_for_route(admitted);
2453 let fresh_roster = fresh_roster_listing(provider, &identity, &base_url);
2454 for declaration in config.custom_models.as_deref().unwrap_or_default() {
2455 if crate::provider_lake::configured_model_for_route(
2456 config,
2457 provider,
2458 &identity,
2459 &base_url,
2460 &declaration.id,
2461 )
2462 .is_some()
2463 && !model_ids.contains(&declaration.id)
2464 {
2465 model_ids.push(declaration.id.clone());
2466 }
2467 }
2468 for id in model_ids {
2469 if id == "auto" {
2470 continue;
2471 }
2472 let readiness =
2473 crate::provider_readiness::resolve_for_model(config, admitted, &id, provider_health);
2474 let selectable = readiness.can_attempt();
2475 let readiness_label = readiness.label();
2476 let roster_entry = if provider == ProviderKind::OpenaiCodex {
2477 codex_roster.metadata_for(&id)
2478 } else {
2479 None
2480 };
2481 let codex_metadata = if codex_roster.freshness == CodexModelCacheFreshness::Fresh {
2482 roster_entry
2483 } else {
2484 None
2485 };
2486 let codex_freshness = roster_entry.map(|_| codex_roster.freshness);
2487 let metadata = effective_picker_metadata_with_codex(
2488 config,
2489 Some(provider),
2490 Some(identity.as_str()),
2491 &id,
2492 codex_metadata,
2493 );
2494 let provider_catalog_receipt = provider_catalog_receipt_for_route(
2495 provider,
2496 Some(identity.as_str()),
2497 config,
2498 metadata.source.as_ref(),
2499 );
2500 let mut hint = render_picker_model_hint(
2501 &id,
2502 Some(provider),
2503 &metadata,
2504 codex_freshness,
2505 provider_catalog_receipt.as_ref(),
2506 );
2507 if metadata.display_name.is_some() {
2508 hint = format!("{id} · {hint}");
2509 }
2510 hint = format!("{readiness_label} · {hint}");
2511 if provider != active_provider {
2512 hint = format!("switch route · {hint}");
2513 }
2514 let blocked_reason = (!selectable).then(|| readiness_label.to_string());
2515 let credentialed = matches!(
2516 readiness,
2517 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked
2518 | crate::provider_readiness::ResolvedProviderReadiness::ImportedTokenUnchecked
2519 | crate::provider_readiness::ResolvedProviderReadiness::Ready
2520 | crate::provider_readiness::ResolvedProviderReadiness::ConnectionCheckedModelUnchecked
2521 | crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed { .. }
2522 );
2523 let not_listed = fresh_roster.as_ref().and_then(|(listed, checked)| {
2524 (!listed.iter().any(|listed| listed.eq_ignore_ascii_case(&id))).then(|| NotListed {
2525 provider: if provider == ProviderKind::Custom {
2526 identity.clone()
2527 } else {
2528 provider.provider().display_name().to_string()
2529 },
2530 checked: checked.clone(),
2531 })
2532 });
2533 let before = rows.len();
2534 push_model_row(
2535 rows,
2536 id.clone(),
2537 Some(provider),
2538 Some(identity.clone()),
2539 hint,
2540 metadata,
2541 selectable,
2542 blocked_reason,
2543 );
2544 if rows.len() > before
2545 && let Some(row) = rows.last_mut()
2546 {
2547 row.admitted_identity = Some(admitted.clone());
2548 row.credentialed = credentialed;
2549 row.not_listed = not_listed;
2550 }
2551 }
2552 }
2553
2554 /// Ids a FRESH live roster lists for this exact route, with the fetch date.
2555 /// `None` when no fresh roster exists — a stale, failed, or absent roster
2556 /// cannot prove an id is gone (the same rule as
2557 /// `provider_catalog_live::pin_missing_from_fresh_roster`, #6035). Attested
2558 /// unlisted ids count as listed.
2559 fn fresh_roster_listing(
2560 provider: ProviderKind,
2561 identity: &str,
2562 base_url: &str,
2563 ) -> Option<(Vec<String>, String)> {
2564 if provider == ProviderKind::OpenaiCodex {
2565 return None;
2566 }
2567 // Reading the entry first loads a roster an earlier process persisted.
2568 let entry = crate::provider_catalog_live::cached_entry_for_route(provider, identity, base_url)
2569 .ok()
2570 .flatten()?;
2571 if crate::provider_catalog_live::status_for_route(provider, identity, base_url)
2572 != CatalogStatus::Fresh
2573 {
2574 return None;
2575 }
2576 let mut listed = crate::provider_lake::catalog_models_for_route(provider, identity, base_url);
2577 for offering in &entry.offerings {
2578 listed.push(picker_visible_model_id(provider, &offering.wire_model_id, false).to_string());
2579 if let Some(canonical) = &offering.canonical_model {
2580 listed.push(canonical.clone());
2581 }
2582 }
2583 let checked = chrono::DateTime::from_timestamp(i64::try_from(entry.fetched_at).ok()?, 0)?
2584 .format("%Y-%m-%d")
2585 .to_string();
2586 Some((listed, checked))
2587 }
2588
2589 fn provider_catalog_receipt_for_route(
2590 provider: ProviderKind,
2591 provider_identity: Option<&str>,
2592 config: &Config,
2593 source: Option<&CatalogSource>,
2594 ) -> Option<(CatalogStatus, bool)> {
2595 let admitted = match provider_identity {
2596 Some(key) => config.legacy_selection_identity(key),
2597 None => config.builtin_provider_identity(provider),
2598 }
2599 .ok()?;
2600 (admitted.provider == provider).then_some(())?;
2601 let identity = admitted.key.as_str();
2602 // One predicate with the active-provider refresh: any route whose roster
2603 // is probed (named live gateways and custom hosts) reports its freshness
2604 // here, so a widened probe cannot leave this receipt behind.
2605 if !crate::provider_catalog_live::provider_owns_live_catalog(provider) {
2606 return None;
2607 }
2608
2609 let base_url = config.base_url_for_route(&admitted);
2610 let endpoint_matches = match source {
2611 Some(CatalogSource::Live {
2612 base_url_fingerprint,
2613 ..
2614 }) => *base_url_fingerprint == codewhale_config::catalog::base_url_fingerprint(&base_url),
2615 // A bundled/template fallback has no endpoint claim to compare. Its
2616 // exact-scope status still matters: a first refresh failure must be
2617 // visible even though no live row exists yet.
2618 _ => true,
2619 };
2620 Some((
2621 crate::provider_catalog_live::status_for_route(provider, identity, &base_url),
2622 endpoint_matches,
2623 ))
2624 }
2625
2626 fn push_auto_model_row(rows: &mut Vec<ModelPickerRow>, app: &App, config: &Config, hint: &str) {
2627 let captured = app.admitted_provider_identity().ok();
2628 let readiness = captured.map_or(
2629 crate::provider_readiness::ResolvedProviderReadiness::Legacy,
2630 |identity| {
2631 crate::provider_readiness::resolve_for_model(
2632 config,
2633 identity,
2634 "auto",
2635 &app.provider_health,
2636 )
2637 },
2638 );
2639 let metadata = effective_picker_metadata(config, None, "auto");
2640 let selectable = captured.is_some() && readiness.can_attempt();
2641 let blocked_reason = (!selectable).then(|| readiness.label().to_string());
2642 push_model_row(
2643 rows,
2644 "auto".to_string(),
2645 None,
2646 None,
2647 format!("{} · {hint}", readiness.label()),
2648 metadata,
2649 selectable,
2650 blocked_reason,
2651 );
2652 if let Some(row) = rows.last_mut() {
2653 row.admitted_identity = captured.cloned();
2654 }
2655 }
2656
2657 fn auto_picker_hint(app: &App, config: &Config) -> String {
2658 let inventory = match crate::model_inventory::ModelInventory::from_config(config) {
2659 Ok(inventory) => inventory,
2660 Err(reason) => {
2661 return format!("{} · {reason}", app.tr(MessageId::ModelPickerAutoLocalHint));
2662 }
2663 };
2664 // #4411: the classifier only sees other providers under the persisted
2665 // `[auto] cross_provider` opt-in, so the default hint says active provider
2666 // only and names the classifier route it will actually call.
2667 let hint_id = match (inventory.router_available, inventory.cross_provider_auto) {
2668 (true, true) => MessageId::ModelPickerAutoNetworkHint,
2669 (true, false) => MessageId::ModelPickerAutoNetworkActiveProviderHint,
2670 (false, _) => MessageId::ModelPickerAutoLocalHint,
2671 };
2672 let mut hint = app
2673 .tr(hint_id)
2674 .into_owned()
2675 .replace(
2676 "{provider}",
2677 inventory.router_provider.provider().display_name(),
2678 )
2679 .replace("{model}", &inventory.router_model);
2680 if let (Some(provider), Some(model)) = (
2681 app.last_effective_provider,
2682 app.last_effective_model.as_deref(),
2683 ) {
2684 let provider_label = if provider == ProviderKind::Custom {
2685 app.last_effective_provider_identity
2686 .as_deref()
2687 .unwrap_or_else(|| app.provider_identity_for_persistence())
2688 } else {
2689 provider.provider().display_name()
2690 };
2691 let last = app
2692 .tr(MessageId::ModelPickerAutoLastRoute)
2693 .replace("{provider}", provider_label)
2694 .replace("{model}", model);
2695 hint.push_str(" · ");
2696 hint.push_str(&last);
2697 }
2698 hint
2699 }
2700
2701 fn push_configured_provider_model(
2702 models: &mut Vec<String>,
2703 config: &Config,
2704 provider: ProviderKind,
2705 identity: &str,
2706 ) {
2707 let Ok(admitted) = config.legacy_selection_identity(identity) else {
2708 return;
2709 };
2710 if admitted.provider != provider {
2711 return;
2712 }
2713 if let Some(model) = route_provider_config(config, provider, identity)
2714 .and_then(|entry| entry.model.as_deref())
2715 .map(str::trim)
2716 .filter(|model| !model.is_empty())
2717 {
2718 push_model_id(
2719 models,
2720 picker_visible_model_id(
2721 provider,
2722 model,
2723 config.model_ids_pass_through_for_provider(&admitted),
2724 ),
2725 );
2726 }
2727 }
2728
2729 fn provider_catalog_model_ids(
2730 provider: ProviderKind,
2731 identity: &str,
2732 base_url: &str,
2733 ) -> Vec<String> {
2734 let mut models = Vec::new();
2735 for id in crate::provider_lake::catalog_models_for_route(provider, identity, base_url) {
2736 // Cached IDs belong to this exact endpoint. The configured/current
2737 // model is appended separately so users can still select saved IDs.
2738 push_model_id(&mut models, picker_visible_model_id(provider, &id, false));
2739 }
2740 models
2741 }
2742
2743 fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) -> Vec<String> {
2744 // `include_current_model` is for completion surfaces that do not have a
2745 // separate custom/current-model row.
2746 let mut models = Vec::new();
2747 push_model_id(&mut models, "auto");
2748 for id in provider_catalog_model_ids(
2749 app.api_provider,
2750 app.provider_identity_for_persistence(),
2751 &app.active_route_base_url,
2752 ) {
2753 push_model_id(&mut models, &id);
2754 }
2755
2756 if app.api_provider != ProviderKind::OpenaiCodex
2757 && codewhale_config::catalog::configured::validate_configured_models(&app.configured_models)
2758 .is_ok()
2759 {
2760 for declaration in app.configured_models.iter().filter(|declaration| {
2761 declaration.matches_route(
2762 app.provider_identity_for_persistence(),
2763 &app.active_route_base_url,
2764 )
2765 }) {
2766 if !models.contains(&declaration.id) {
2767 models.push(declaration.id.clone());
2768 }
2769 }
2770 }
2771
2772 if let Some(model) = app
2773 .provider_models
2774 .get(app.provider_identity_for_persistence())
2775 .map(|model| model.trim())
2776 .filter(|model| !model.is_empty())
2777 {
2778 push_model_id(
2779 &mut models,
2780 picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
2781 );
2782 }
2783
2784 if include_current_model && !app.auto_model {
2785 push_model_id(
2786 &mut models,
2787 picker_visible_model_id(
2788 app.api_provider,
2789 app.model.trim(),
2790 app.accepts_custom_model_ids(),
2791 ),
2792 );
2793 }
2794
2795 models
2796 }
2797
2798 fn push_model_id(models: &mut Vec<String>, model: &str) {
2799 let model = model.trim();
2800 if model.is_empty() {
2801 return;
2802 }
2803 if !models
2804 .iter()
2805 .any(|existing| existing.eq_ignore_ascii_case(model))
2806 {
2807 models.push(model.to_string());
2808 }
2809 }
2810
2811 /// Migrate retired aliases out of first-party DeepSeek model choices. Custom
2812 /// endpoints and aggregators own their namespaces, where `deepseek-reasoner`
2813 /// can remain a native wire id.
2814 fn picker_visible_model_id(
2815 provider: ProviderKind,
2816 model: &str,
2817 preserve_endpoint_model_ids: bool,
2818 ) -> &str {
2819 if !preserve_endpoint_model_ids
2820 && matches!(
2821 provider,
2822 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
2823 )
2824 && (model.eq_ignore_ascii_case("deepseek-chat")
2825 || model.eq_ignore_ascii_case("deepseek-reasoner"))
2826 {
2827 DEEPSEEK_ALIAS_REPLACEMENT
2828 } else {
2829 model
2830 }
2831 }
2832
2833 fn provider_query_splits(query: &str) -> Vec<(&str, &str)> {
2834 let trimmed = query.trim();
2835 let mut splits = Vec::new();
2836 if let Some((provider, model)) = trimmed.split_once(':') {
2837 splits.push((provider.trim(), model.trim()));
2838 }
2839 if let Some(idx) = trimmed.find(char::is_whitespace) {
2840 let (provider, model) = trimmed.split_at(idx);
2841 splits.push((provider.trim(), model.trim()));
2842 }
2843 splits
2844 }
2845
2846 fn push_model_row(
2847 rows: &mut Vec<ModelPickerRow>,
2848 id: String,
2849 provider: Option<ProviderKind>,
2850 provider_identity: Option<String>,
2851 hint: String,
2852 metadata: EffectivePickerMetadata,
2853 selectable: bool,
2854 blocked_reason: Option<String>,
2855 ) {
2856 if rows.iter().any(|row| {
2857 row.id == id
2858 && row.provider == provider
2859 && match (
2860 row.provider_identity.as_deref(),
2861 provider_identity.as_deref(),
2862 ) {
2863 (Some(left), Some(right)) => left == right,
2864 (None, None) => true,
2865 _ => false,
2866 }
2867 }) {
2868 return;
2869 }
2870 rows.push(ModelPickerRow {
2871 id,
2872 provider,
2873 provider_identity,
2874 admitted_identity: None,
2875 hint,
2876 metadata,
2877 selectable,
2878 blocked_reason,
2879 default_rank: None,
2880 credentialed: false,
2881 not_listed: None,
2882 });
2883 }
2884
2885 /// Compact Models.dev freshness chip for the picker chrome (#4139).
2886 ///
2887 /// Fresh/live rows stay unmarked; stale and failed caches get an explicit
2888 /// suffix so users know the live layer is still visible but not current.
2889 fn catalog_freshness_title_suffix() -> &'static str {
2890 catalog_freshness_title_suffix_for(models_dev_live::status().freshness)
2891 }
2892
2893 fn catalog_freshness_title_suffix_for(freshness: ModelsDevFreshness) -> &'static str {
2894 match freshness {
2895 ModelsDevFreshness::Stale => " · cached catalog",
2896 ModelsDevFreshness::Failed => " · refresh failed; catalog available",
2897 ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
2898 }
2899 }
2900
2901 /// Cross-field search (#4141): match a query against the provider name
2902 /// (provider key + display name), the display model name, and the wire model
2903 /// id, mirroring `ProviderDashboardRow::matches_query` so the two pickers behave
2904 /// consistently. `row.id` is both the model's display name and the id it is
2905 /// sent to the provider as, so matching it covers the display model name and
2906 /// the wire model id. The compact hint is only searched for the active
2907 /// provider / `auto` rows, preserving the existing cross-provider behavior.
2908 fn model_row_matches_query(
2909 row: &ModelPickerRow,
2910 query: &str,
2911 initial_provider: ProviderKind,
2912 ) -> bool {
2913 let query = query.trim().to_ascii_lowercase();
2914 if query.is_empty() {
2915 return true;
2916 }
2917 let normalized_query = normalize_picker_search_text(&query);
2918 let matches = |candidate: &str| {
2919 let candidate = candidate.to_ascii_lowercase();
2920 candidate.contains(&query)
2921 || normalize_picker_search_text(&candidate).contains(&normalized_query)
2922 };
2923 let provider_matches = row.provider.is_some_and(|provider| {
2924 row.provider_identity.as_deref().is_some_and(matches)
2925 || matches(provider.as_str())
2926 || matches(row_provider_label(row))
2927 });
2928 provider_matches
2929 || row.metadata.display_name.as_deref().is_some_and(matches)
2930 || matches(&row.id)
2931 || ((row.provider.is_none() || row.provider == Some(initial_provider))
2932 && matches(&row.hint))
2933 }
2934
2935 fn normalize_picker_search_text(text: &str) -> String {
2936 text.chars()
2937 .map(|ch| {
2938 if ch.is_ascii_alphanumeric() {
2939 ch.to_ascii_lowercase()
2940 } else {
2941 ' '
2942 }
2943 })
2944 .collect::<String>()
2945 .split_whitespace()
2946 .collect::<Vec<_>>()
2947 .join(" ")
2948 }
2949
2950 /// Route-identity labels for a set of rows, disambiguated where two providers
2951 /// answer to the same display name.
2952 ///
2953 /// `Deepseek` and `DeepseekAnthropic` are both spelled "DeepSeek", so a picker
2954 /// listing both showed two rows of literally identical text for two genuinely
2955 /// different endpoints. When a display name is not unique among the rows on
2956 /// offer, the provider's own id — the `[providers.<id>]` key the user would
2957 /// edit — supplies the discriminator, with the leading run it already shares
2958 /// with the display name removed so the suffix is the part that differs.
2959 fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<String, String> {
2960 let mut by_display: BTreeMap<&'static str, Vec<ProviderKind>> = BTreeMap::new();
2961 for provider in rows
2962 .iter()
2963 .filter_map(|row| row.provider)
2964 .filter(|provider| *provider != ProviderKind::Custom)
2965 {
2966 let bucket = by_display
2967 .entry(provider.provider().display_name())
2968 .or_default();
2969 if !bucket.contains(&provider) {
2970 bucket.push(provider);
2971 }
2972 }
2973 let mut labels = BTreeMap::new();
2974 for (display, providers) in by_display {
2975 let ambiguous = providers.len() > 1;
2976 for provider in providers {
2977 let label = match ambiguous.then(|| route_discriminator(display, provider.as_str())) {
2978 Some(Some(suffix)) => format!("{display} {suffix}"),
2979 // The canonical route — the one whose id is just the display
2980 // name — keeps the bare name; provider ids are unique, so at
2981 // most one member of a group can land here and the labels stay
2982 // distinct.
2983 Some(None) | None => display.to_string(),
2984 };
2985 labels.insert(provider.as_str().to_string(), label);
2986 }
2987 }
2988 for row in rows
2989 .iter()
2990 .filter(|row| row.provider == Some(ProviderKind::Custom))
2991 {
2992 let Some(identity) = row_provider_identity(row) else {
2993 continue;
2994 };
2995 // Custom tables are labeled by their `[providers.<id>]` key: there
2996 // are no compiled display names anymore (#6289).
2997 labels
2998 .entry(identity.to_string())
2999 .or_insert_with(|| identity.to_string());
3000 }
3001 labels
3002 }
3003
3004 /// The part of a provider id that is not already carried by its display name.
3005 fn route_discriminator(display: &str, provider_id: &str) -> Option<String> {
3006 let squash = |text: &str| -> String {
3007 text.chars()
3008 .filter(|c| c.is_alphanumeric())
3009 .collect::<String>()
3010 };
3011 let display_key = squash(display).to_ascii_lowercase();
3012 let id_key = squash(provider_id).to_ascii_lowercase();
3013 if display_key.is_empty() || !id_key.starts_with(&display_key) {
3014 return None;
3015 }
3016 // Walk the raw id until the display name's alphanumerics are consumed; what
3017 // remains is the endpoint-specific tail (`-anthropic`, `-CN`, …).
3018 // Count CHARACTERS, not bytes: `display_key.len()` is a byte length, and
3019 // for a non-ASCII display name it exceeds the alphanumeric char count, so
3020 // the loop would over-consume and the discriminator would be wrong or
3021 // empty (2026-08-04 review).
3022 let display_key_chars = display_key.chars().count();
3023 let mut consumed = 0usize;
3024 let mut tail = provider_id;
3025 for (offset, ch) in provider_id.char_indices() {
3026 if consumed == display_key_chars {
3027 tail = &provider_id[offset..];
3028 break;
3029 }
3030 if ch.is_alphanumeric() {
3031 consumed += 1;
3032 }
3033 tail = &provider_id[offset + ch.len_utf8()..];
3034 }
3035 let tail = tail.trim_matches(|c: char| !c.is_alphanumeric());
3036 (!tail.is_empty()).then(|| tail.to_string())
3037 }
3038
3039 /// The handful of facts that actually differ between neighbouring model rows,
3040 /// in the order they earn their space.
3041 ///
3042 /// Everything the old prose hint carried but that reads the same on nearly
3043 /// every row — `tools`, `no vision`, `price unknown`, `bundled` — is dropped
3044 /// here: a token repeated on forty rows cannot tell them apart, and it is what
3045 /// pushed the differentiating tokens off the end of the line. Facts the
3046 /// registry does not know are omitted rather than guessed.
3047 /// The picker section label for a provider/model row.
3048 ///
3049 /// Catalog families are useful grouping metadata, but they are not model names.
3050 /// DeepSeek has published both `deepseek` and `deepseek-thinking` as family
3051 /// values for its current V4 models, so keep its picker heading stable and
3052 /// provider-facing rather than exposing either implementation detail.
3053 fn catalog_family_for_identity(
3054 provider: ProviderKind,
3055 provider_identity: Option<&str>,
3056 model_id: &str,
3057 ) -> Option<String> {
3058 if provider == ProviderKind::Deepseek {
3059 return Some(provider.provider().display_name().to_string());
3060 }
3061 catalog_offering_for_model_identity(provider, provider_identity, model_id)
3062 .and_then(|offering| offering.family)
3063 }
3064
3065 fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
3066 if row.metadata.source == Some(CatalogSource::ConfigOverride) {
3067 // The qualifier comes first so compact rows cannot shed it while
3068 // keeping an unverified declaration visible as a provider fact.
3069 return vec![
3070 "user declared (unverified)".to_string(),
3071 row.metadata
3072 .context_window
3073 .map(|value| format_context_window(u64::from(value)))
3074 .unwrap_or_else(|| "context unknown".into()),
3075 row.metadata
3076 .max_output
3077 .map(|value| format!("{} out", format_context_window(u64::from(value))))
3078 .unwrap_or_else(|| "output unknown".into()),
3079 match &row.metadata.pricing {
3080 PickerPricing::Known(price) => format!("estimate {price}"),
3081 _ => "price unknown".into(),
3082 },
3083 if row.metadata.reasoning_unknown {
3084 "reasoning unknown"
3085 } else if row.metadata.reasoning {
3086 "reasoning"
3087 } else {
3088 "no reasoning"
3089 }
3090 .into(),
3091 match row.metadata.tool_calls {
3092 Some(true) => "tools declared",
3093 Some(false) => "no tools",
3094 None => "tools unknown",
3095 }
3096 .into(),
3097 match row.metadata.vision {
3098 SupportState::Supported => "vision declared",
3099 SupportState::Unsupported => "text only",
3100 SupportState::Unknown => "vision unknown",
3101 }
3102 .into(),
3103 ];
3104 }
3105 let mut chips = Vec::new();
3106 if let Some(context_window) = row.metadata.context_window {
3107 chips.push(format_context_window(u64::from(context_window)));
3108 }
3109 // The reasoning stance is the most decision-relevant fact for a coding
3110 // harness, so it sits before the limits/modality chips — the chip budget
3111 // sheds from the tail, and a squeezed row must never lose the stance.
3112 chips.push(
3113 if row.metadata.reasoning {
3114 "reasoning"
3115 } else {
3116 "no reasoning"
3117 }
3118 .to_string(),
3119 );
3120 // #5239/#5441: an unverified window still drives budgets, but the chip
3121 // must not lend it a verified reading. Honesty rides as its own chip
3122 // *after* the stance so a squeezed row sheds the marker before it ever
3123 // sheds the stance; the full "(unverified)" prose lives in the hint
3124 // line, which always renders it.
3125 if row.metadata.context_window_unverified {
3126 chips.push("unverified ctx".to_string());
3127 }
3128 if let Some(max_output) = row.metadata.max_output {
3129 // #5440: an assumed floor is shown as such, never as a documented
3130 // ceiling.
3131 let suffix = if row.metadata.max_output_unverified {
3132 " (assumed floor)"
3133 } else {
3134 ""
3135 };
3136 chips.push(format!(
3137 "{} out{suffix}",
3138 format_context_window(u64::from(max_output))
3139 ));
3140 }
3141 // Modality and tool facts are shown only when the catalog genuinely knows
3142 // them — an unknown is never rendered as a claim.
3143 match row.metadata.vision {
3144 SupportState::Supported => chips.push("vision".to_string()),
3145 SupportState::Unsupported => chips.push("text only".to_string()),
3146 SupportState::Unknown => {}
3147 }
3148 if let Some(tool_calls) = row.metadata.tool_calls {
3149 chips.push(if tool_calls {
3150 "tools".to_string()
3151 } else {
3152 "no tools".to_string()
3153 });
3154 }
3155 if let Some(reason) = row.blocked_reason.as_deref() {
3156 chips.push(reason.to_string());
3157 }
3158 chips
3159 }
3160
3161 /// Join metadata chips, dropping the lowest-priority ones until the result
3162 /// fits. Truncating mid-chip would render a half-word fact, so whole chips are
3163 /// shed instead.
3164 fn fit_meta_chips(chips: &[String], width: usize) -> String {
3165 for take in (1..=chips.len()).rev() {
3166 let joined = chips[..take].join(" · ");
3167 if unicode_width::UnicodeWidthStr::width(joined.as_str()) <= width {
3168 return joined;
3169 }
3170 }
3171 // A single chip that still does not fit is prose (an `auto` explanation or
3172 // an effort description) rather than a fact token, so it is truncated
3173 // instead of dropped — but only when the column can hold something worth
3174 // reading.
3175 match chips.first() {
3176 Some(first) if width >= MIN_META_WIDTH => fit_text(first, width),
3177 _ => String::new(),
3178 }
3179 }
3180
3181 /// Whether a model row shows in the active catalog view (#3830 / #4115).
3182 fn model_row_visible_in_view(row: &ModelPickerRow, view: ModelListView) -> bool {
3183 match view {
3184 ModelListView::Configured => model_row_visible_by_default(row),
3185 ModelListView::Catalog => true,
3186 ModelListView::New
3187 | ModelListView::Coding
3188 | ModelListView::Cheap
3189 | ModelListView::LongContext => {
3190 // Discoverability views browse the full lake but hide the synthetic
3191 // `auto` row — it is not a catalog offering.
3192 row.provider.is_some() || row.id != "auto"
3193 }
3194 }
3195 }
3196
3197 /// Whether a model row shows up without the user typing a search query:
3198 /// `auto` and every row placed in a default-view section (#6533). The rest of
3199 /// every roster, the active one included, is one search or ⇧A away.
3200 fn model_row_visible_by_default(row: &ModelPickerRow) -> bool {
3201 row.provider.is_none() || row.default_rank.is_some()
3202 }
3203
3204 fn model_row_matches_route(row: &ModelPickerRow, provider: ProviderKind, identity: &str) -> bool {
3205 row.provider.is_none()
3206 || (row.provider == Some(provider) && row_provider_identity(row) == Some(identity))
3207 }
3208
3209 fn sort_model_rows_for_view<'a, T>(
3210 rows: &mut [T],
3211 model_row: impl Fn(&T) -> &'a ModelPickerRow,
3212 view: ModelListView,
3213 pins: &[PinnedModel],
3214 ) {
3215 use std::cmp::Reverse;
3216 // Same route match as marking and labelling (`pin_names_row`), so a row
3217 // never sorts into the pinned block without carrying its pin.
3218 let pin_rank = |row: &ModelPickerRow| {
3219 pins.iter()
3220 .position(|pin| pin_names_row(pin, row))
3221 .unwrap_or(usize::MAX)
3222 };
3223 match view {
3224 // Sections in order; within one, a row a fresh roster no longer lists
3225 // goes last, then the section's own rank.
3226 ModelListView::Configured => rows.sort_by_cached_key(|item| {
3227 let row = model_row(item);
3228 (
3229 row.default_rank.map(|(section, _)| section),
3230 row.not_listed.is_some(),
3231 row.default_rank.map(|(_, rank)| rank),
3232 row_group_key(row),
3233 Reverse(model_version_key(&row.id)),
3234 row.id.clone(),
3235 )
3236 }),
3237 ModelListView::Catalog => rows.sort_by_cached_key(|item| {
3238 let row = model_row(item);
3239 (
3240 pin_rank(row),
3241 row_group_key(row),
3242 Reverse(model_version_key(&row.id)),
3243 row.id.clone(),
3244 )
3245 }),
3246 ModelListView::New => rows.sort_by_cached_key(|item| {
3247 let row = model_row(item);
3248 (Reverse(offering_fetched_at(row)), row.id.clone())
3249 }),
3250 ModelListView::Coding => rows.sort_by_cached_key(|item| {
3251 let row = model_row(item);
3252 (Reverse(coding_score(row)), row.id.clone())
3253 }),
3254 ModelListView::Cheap => {
3255 // Catalog lookup/pricing parsing happens once per row. Unknown
3256 // prices stay last; f64 retains its existing partial-order behavior.
3257 let prices: BTreeMap<_, _> = rows
3258 .iter()
3259 .map(|item| {
3260 let row = model_row(item);
3261 (
3262 (
3263 row_provider_identity(row).unwrap_or_default().to_string(),
3264 row.id.clone(),
3265 ),
3266 input_price_per_million(row),
3267 )
3268 })
3269 .collect();
3270 rows.sort_by(|left, right| {
3271 let left = model_row(left);
3272 let right = model_row(right);
3273 let price = |row: &ModelPickerRow| {
3274 prices[&(
3275 row_provider_identity(row).unwrap_or_default().to_string(),
3276 row.id.clone(),
3277 )]
3278 };
3279 match (price(left), price(right)) {
3280 (Some(l), Some(r)) => l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal),
3281 (Some(_), None) => std::cmp::Ordering::Less,
3282 (None, Some(_)) => std::cmp::Ordering::Greater,
3283 (None, None) => std::cmp::Ordering::Equal,
3284 }
3285 .then_with(|| left.id.cmp(&right.id))
3286 });
3287 }
3288 ModelListView::LongContext => rows.sort_by_cached_key(|item| {
3289 let row = model_row(item);
3290 (Reverse(context_tokens(row)), row.id.clone())
3291 }),
3292 }
3293 }
3294
3295 fn sort_model_indices(indices: &mut [usize], rows: &[ModelPickerRow], sort: ModelSort) {
3296 // Precompute owned text once, keeping navigation independent of catalog size.
3297 let keys: BTreeMap<_, _> = indices
3298 .iter()
3299 .map(|index| {
3300 let row = &rows[*index];
3301 (
3302 *index,
3303 (
3304 row.id.to_ascii_lowercase(),
3305 row_provider_identity(row)
3306 .unwrap_or_default()
3307 .to_ascii_lowercase(),
3308 ),
3309 )
3310 })
3311 .collect();
3312 indices.sort_by(|left, right| {
3313 let a = &rows[*left];
3314 let b = &rows[*right];
3315 let order = match sort.column {
3316 ModelSortColumn::Model => keys[left].0.cmp(&keys[right].0),
3317 ModelSortColumn::Provider => keys[left].1.cmp(&keys[right].1),
3318 ModelSortColumn::Context => a.metadata.context_window.cmp(&b.metadata.context_window),
3319 };
3320 let order = if sort.descending {
3321 order.reverse()
3322 } else {
3323 order
3324 };
3325 // Auto remains reachable at the top; missing context stays last in
3326 // either direction rather than pretending to be a zero-sized model.
3327 a.provider
3328 .is_some()
3329 .cmp(&b.provider.is_some())
3330 .then_with(|| {
3331 if sort.column == ModelSortColumn::Context {
3332 a.metadata
3333 .context_window
3334 .is_none()
3335 .cmp(&b.metadata.context_window.is_none())
3336 } else {
3337 std::cmp::Ordering::Equal
3338 }
3339 })
3340 .then(order)
3341 .then_with(|| keys[left].cmp(&keys[right]))
3342 });
3343 }
3344
3345 /// Stable grouping key so a provider's families render as one contiguous
3346 /// block each, which is what the family-header logic already assumes when it
3347 /// only compares against the previous row.
3348 fn row_group_key(row: &ModelPickerRow) -> (String, String) {
3349 let provider = row_provider_identity(row)
3350 .map(str::to_ascii_lowercase)
3351 .or_else(|| {
3352 row.provider
3353 .map(|provider| provider.as_str().to_ascii_lowercase())
3354 })
3355 .unwrap_or_default();
3356 let family = row
3357 .provider
3358 .and_then(|provider| {
3359 catalog_family_for_identity(provider, row.provider_identity.as_deref(), &row.id)
3360 })
3361 .unwrap_or_default()
3362 .to_ascii_lowercase();
3363 (provider, family)
3364 }
3365
3366 /// Version ordinal for "newest first" inside a family.
3367 ///
3368 /// Model ids carry their version as dotted or dashed numbers (`GLM-5.3`,
3369 /// `deepseek-v4-pro`, `gpt-5.6-terra`), so the comparison is on the numeric
3370 /// components in order, not on the string — otherwise `GLM-5.10` would sort
3371 /// below `GLM-5.2`. Ids with no digits compare equal and fall through to the
3372 /// alphabetical tiebreak.
3373 fn model_version_key(id: &str) -> Vec<u32> {
3374 let mut parts = Vec::new();
3375 let mut current: Option<u32> = None;
3376 for ch in id.chars() {
3377 if let Some(digit) = ch.to_digit(10) {
3378 current = Some(
3379 current
3380 .unwrap_or(0)
3381 .saturating_mul(10)
3382 .saturating_add(digit),
3383 );
3384 } else if let Some(value) = current.take() {
3385 parts.push(value);
3386 }
3387 }
3388 if let Some(value) = current {
3389 parts.push(value);
3390 }
3391 parts
3392 }
3393
3394 fn row_provider_label(row: &ModelPickerRow) -> &str {
3395 let Some(key) = row_provider_identity(row) else {
3396 return "unavailable";
3397 };
3398 if row.provider == Some(ProviderKind::Custom) {
3399 return key;
3400 }
3401 codewhale_config::descriptors::compatibility_for_id(key)
3402 .filter(|metadata| Some(metadata.kind) == row.provider)
3403 .map_or(key, |metadata| metadata.label)
3404 }
3405
3406 fn row_provider_identity(row: &ModelPickerRow) -> Option<&str> {
3407 row.provider_identity.as_deref().or_else(|| {
3408 row.provider
3409 .filter(|provider| *provider != ProviderKind::Custom)
3410 .map(ProviderKind::as_str)
3411 })
3412 }
3413
3414 fn offering_for_row(row: &ModelPickerRow) -> Option<codewhale_config::catalog::CatalogOffering> {
3415 let provider = row.provider?;
3416 catalog_offering_for_model_identity(provider, row.provider_identity.as_deref(), &row.id)
3417 }
3418
3419 fn offering_fetched_at(row: &ModelPickerRow) -> u64 {
3420 match offering_for_row(row).map(|o| o.source) {
3421 Some(
3422 CatalogSource::Live { fetched_at, .. } | CatalogSource::CloudFacts { fetched_at, .. },
3423 ) => fetched_at,
3424 _ => 0,
3425 }
3426 }
3427
3428 fn context_tokens(row: &ModelPickerRow) -> u64 {
3429 row.metadata.context_window.map(u64::from).unwrap_or(0)
3430 }
3431
3432 fn input_price_per_million(row: &ModelPickerRow) -> Option<f64> {
3433 if matches!(row.metadata.source, Some(CatalogSource::ConfigOverride)) {
3434 return row
3435 .metadata
3436 .declared_input_price
3437 .as_ref()
3438 .and_then(|price| price.parse().ok());
3439 }
3440 if matches!(row.metadata.pricing, PickerPricing::Unavailable) {
3441 return None;
3442 }
3443 offering_for_row(row)
3444 .and_then(|offering| OfferingPricing::from_catalog_offering(&offering))
3445 .and_then(|pricing| pricing.input_per_million)
3446 }
3447
3448 fn coding_score(row: &ModelPickerRow) -> u32 {
3449 let mut score = 0_u32;
3450 if let Some(offering) = offering_for_row(row) {
3451 let text_ok = offering.modalities.as_ref().is_none_or(|modalities| {
3452 modalities.output.is_empty()
3453 || modalities
3454 .output
3455 .iter()
3456 .any(|m| m.eq_ignore_ascii_case("text"))
3457 });
3458 if text_ok {
3459 score += 40;
3460 }
3461 }
3462 if row.metadata.tool_calls == Some(true) {
3463 score += 40;
3464 }
3465 if row.metadata.reasoning {
3466 score += 10;
3467 }
3468 if row.metadata.context_window.unwrap_or(0) >= 100_000 {
3469 score += 10;
3470 }
3471 score
3472 }
3473
3474 fn effective_picker_metadata(
3475 config: &Config,
3476 provider: Option<ProviderKind>,
3477 id: &str,
3478 ) -> EffectivePickerMetadata {
3479 effective_picker_metadata_for_identity(config, provider, None, id)
3480 }
3481
3482 fn effective_picker_metadata_for_identity(
3483 config: &Config,
3484 provider: Option<ProviderKind>,
3485 provider_identity: Option<&str>,
3486 id: &str,
3487 ) -> EffectivePickerMetadata {
3488 effective_picker_metadata_with_codex(config, provider, provider_identity, id, None)
3489 }
3490
3491 fn effective_picker_metadata_with_codex(
3492 config: &Config,
3493 provider: Option<ProviderKind>,
3494 provider_identity: Option<&str>,
3495 id: &str,
3496 codex_metadata: Option<&CodexModelMetadata>,
3497 ) -> EffectivePickerMetadata {
3498 let captured = provider.and_then(|kind| {
3499 let identity = match provider_identity {
3500 Some(key) => config.legacy_selection_identity(key),
3501 None => config.builtin_provider_identity(kind),
3502 }
3503 .ok()?;
3504 (identity.provider == kind).then_some(identity)
3505 });
3506 if provider.is_some() && captured.is_none() {
3507 return EffectivePickerMetadata::default();
3508 }
3509 let offering = captured.as_ref().and_then(|identity| {
3510 let base_url = config.base_url_for_route(identity);
3511 crate::provider_lake::configured_catalog_offering_for_route(
3512 config,
3513 identity.provider,
3514 identity.key.as_str(),
3515 &base_url,
3516 id,
3517 )
3518 });
3519 let card = offering.as_ref().map(ModelReferenceCard::from_offering);
3520 let registry = codewhale_config::catalog::reviewed::intrinsic_model(id);
3521
3522 let Some(provider) = provider else {
3523 return EffectivePickerMetadata {
3524 context_window: registry.as_ref().and_then(|meta| meta.context_window),
3525 context_window_unverified: false,
3526 max_output: registry
3527 .as_ref()
3528 .and_then(|meta| meta.generation_default.or(meta.max_output)),
3529 max_output_unverified: false,
3530 tool_calls: None,
3531 reasoning: registry
3532 .as_ref()
3533 .is_some_and(|meta| meta.reasoning == Some(true)),
3534 vision: SupportState::Unknown,
3535 pricing: if crate::pricing::has_pricing_for_model(id) {
3536 PickerPricing::Known("priced".to_string())
3537 } else {
3538 PickerPricing::Unknown
3539 },
3540 display_name: None,
3541 reasoning_unknown: false,
3542 declared_input_price: None,
3543 source: None,
3544 };
3545 };
3546
3547 let admitted = captured
3548 .as_ref()
3549 .expect("provider metadata admission checked");
3550 let identity = admitted.key.as_str();
3551 let context_override = config.context_window_for_provider_config(admitted);
3552 let base_url = config.base_url_for_route(admitted);
3553 if let Some(declared) =
3554 crate::provider_lake::configured_model_for_route(config, provider, identity, &base_url, id)
3555 {
3556 return EffectivePickerMetadata {
3557 display_name: declared.display_name.clone(),
3558 declared_input_price: declared
3559 .cost
3560 .as_ref()
3561 .and_then(|cost| cost.input)
3562 .map(|price| price.to_string()),
3563 context_window: context_override.or_else(|| {
3564 declared
3565 .limit
3566 .as_ref()
3567 .and_then(|limit| limit.context)
3568 .and_then(|value| u32::try_from(value).ok())
3569 }),
3570 max_output: declared
3571 .limit
3572 .as_ref()
3573 .and_then(|limit| limit.output)
3574 .and_then(|value| u32::try_from(value).ok()),
3575 tool_calls: declared.tool_call,
3576 reasoning: declared.reasoning.unwrap_or(false),
3577 reasoning_unknown: declared.reasoning.is_none(),
3578 vision: codewhale_config::models_dev::image_input_support(declared.modalities.as_ref()),
3579 pricing: card
3580 .as_ref()
3581 .filter(|card| card.price_label() != "unknown")
3582 .map_or(PickerPricing::Unknown, |card| {
3583 PickerPricing::Known(card.price_label())
3584 }),
3585 source: Some(CatalogSource::ConfigOverride),
3586 ..EffectivePickerMetadata::default()
3587 };
3588 }
3589 if offering.is_none()
3590 && provider != ProviderKind::OpenaiCodex
3591 && codewhale_config::provider_preserves_custom_base_url_model(provider, &base_url)
3592 {
3593 return EffectivePickerMetadata {
3594 context_window: context_override,
3595 ..EffectivePickerMetadata::default()
3596 };
3597 }
3598 let overrides = CapabilityOverride {
3599 context_window: context_override,
3600 ..CapabilityOverride::default()
3601 };
3602 let profile = offering.as_ref().map_or_else(
3603 || resolved_capability_profile_with_overrides(provider, id, overrides.clone()),
3604 |offering| {
3605 let route_offering = offering.to_offering();
3606 resolved_capability_profile_for_route_with_overrides(
3607 provider,
3608 id,
3609 route_offering.capabilities,
3610 route_offering.limits,
3611 overrides.clone(),
3612 )
3613 },
3614 );
3615 let card_context = card
3616 .as_ref()
3617 .and_then(|card| card.context_window)
3618 .map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
3619 let preserves_unknown_limits = offering.is_some()
3620 || (provider == ProviderKind::Together
3621 && id.eq_ignore_ascii_case(crate::config::TOGETHER_INKLING_MODEL));
3622 let context_window = if context_override.is_some() {
3623 profile.context_window
3624 } else if provider == ProviderKind::OpenaiCodex {
3625 codex_metadata.and_then(|metadata| metadata.context_window)
3626 } else if preserves_unknown_limits {
3627 card_context
3628 } else {
3629 profile.context_window
3630 };
3631 let card_output = card
3632 .as_ref()
3633 .and_then(|card| card.max_output)
3634 .map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
3635 // The Codex cache does not publish a route-owned output ceiling. The
3636 // profile's current value is inherited from the same-id OpenAI API model,
3637 // so omitting it is more truthful than claiming that API limit for OAuth.
3638 let max_output = if provider == ProviderKind::OpenaiCodex {
3639 None
3640 } else if preserves_unknown_limits {
3641 card_output
3642 } else {
3643 profile.max_output
3644 };
3645 let profile_tool_calls = match profile.native_tool_calls {
3646 SupportState::Supported => Some(true),
3647 SupportState::Unsupported => Some(false),
3648 SupportState::Unknown => None,
3649 };
3650 let tool_calls = if provider == ProviderKind::OpenaiCodex {
3651 codex_metadata.and(profile_tool_calls)
3652 } else {
3653 offering
3654 .as_ref()
3655 .and_then(|offering| offering.tool_call)
3656 .or(profile_tool_calls)
3657 };
3658 let reasoning = if provider == ProviderKind::OpenaiCodex {
3659 codex_metadata
3660 .map(|metadata| {
3661 metadata
3662 .reasoning
3663 .unwrap_or_else(|| profile.supports_reasoning())
3664 })
3665 .unwrap_or(false)
3666 } else {
3667 offering
3668 .as_ref()
3669 .and_then(|offering| offering.reasoning)
3670 .unwrap_or_else(|| profile.supports_reasoning())
3671 };
3672 let vision = profile.image_input;
3673 let card_price = card.as_ref().and_then(|card| {
3674 let label = card.price_label();
3675 (label != "unknown").then_some(label)
3676 });
3677 let pricing = if provider == ProviderKind::OpenaiCodex {
3678 PickerPricing::Unavailable
3679 } else if let Some(label) = card_price {
3680 PickerPricing::Known(label)
3681 } else if crate::pricing::has_pricing_for_provider(provider, id) {
3682 PickerPricing::Known("priced".to_string())
3683 } else {
3684 PickerPricing::Unknown
3685 };
3686
3687 // Honesty rungs (#5239, #5440, #5441). A window that reached the picker
3688 // only through the legacy provider fallback — no offering, no catalog
3689 // row, no Codex roster, no operator override — is a guess (possibly an
3690 // `_Nk` name-suffix parse), and an unknown Anthropic-family model's
3691 // output ceiling is an assumed floor. Both still drive budgets; both
3692 // must say what they are instead of borrowing a verified label.
3693 let context_window_unverified = context_window.is_some()
3694 && context_override.is_none()
3695 && provider != ProviderKind::OpenaiCodex
3696 && !preserves_unknown_limits
3697 && codewhale_config::catalog::reviewed::intrinsic_model(id)
3698 .and_then(|row| row.context_window)
3699 .is_none();
3700 let max_output_unverified = max_output.is_some()
3701 && matches!(
3702 provider,
3703 ProviderKind::Anthropic | ProviderKind::MinimaxAnthropic | ProviderKind::Openmodel
3704 )
3705 && !preserves_unknown_limits
3706 && codewhale_models::max_output_tokens_for_model(id).is_none();
3707
3708 EffectivePickerMetadata {
3709 context_window,
3710 context_window_unverified,
3711 max_output,
3712 max_output_unverified,
3713 tool_calls,
3714 reasoning,
3715 vision,
3716 pricing,
3717 display_name: codex_metadata.and_then(|model| model.display_name.clone()),
3718 reasoning_unknown: false,
3719 declared_input_price: None,
3720 source: card.map(|card| card.source),
3721 }
3722 }
3723
3724 fn render_picker_model_hint(
3725 id: &str,
3726 provider: Option<ProviderKind>,
3727 metadata: &EffectivePickerMetadata,
3728 codex_freshness: Option<CodexModelCacheFreshness>,
3729 provider_catalog_receipt: Option<&(CatalogStatus, bool)>,
3730 ) -> String {
3731 debug_assert_ne!(id, "auto", "Auto rows use the context-aware picker hint");
3732
3733 let mut parts = Vec::new();
3734
3735 // `k3` and `kimi-k3` are the same underlying model on two different
3736 // products, so bare ids read as a confusing duplicate. Name the route:
3737 // bare `k3` is the Kimi Code membership route (validated pairing with
3738 // the coding endpoint, #4687), `kimi-k3` is the direct open platform.
3739 if provider == Some(ProviderKind::Moonshot) {
3740 match id.trim().to_ascii_lowercase().as_str() {
3741 "k3" => parts.push("Kimi Code plan route".to_string()),
3742 "kimi-k3" | "moonshotai/kimi-k3" => parts.push("Moonshot direct route".to_string()),
3743 _ => {}
3744 }
3745 }
3746
3747 if let Some(context_window) = metadata.context_window {
3748 // The ChatGPT/Codex OAuth roster reports account-scoped windows (e.g.
3749 // 272K for gpt-5.x) that differ from the API route's limits by
3750 // deliberate policy. Label the value as route-scoped so it reads as a
3751 // route fact, not a wrong generic model limit (TUI-DOG-016).
3752 if provider == Some(ProviderKind::OpenaiCodex) {
3753 parts.push(format!(
3754 "{} ctx · ChatGPT route",
3755 format_context_window(u64::from(context_window))
3756 ));
3757 } else if provider == Some(ProviderKind::Moonshot)
3758 && id.trim().eq_ignore_ascii_case("k3")
3759 && context_window == codewhale_models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS
3760 {
3761 // The membership route's real window is plan-tier dependent
3762 // (256K on lower tiers, up to 1M on higher ones); this default
3763 // is the safe floor, raisable via the provider's
3764 // `context_window` setting when the plan includes 1M.
3765 parts.push(format!(
3766 "{} ctx (plan floor; raise via context_window)",
3767 format_context_window(u64::from(context_window))
3768 ));
3769 } else {
3770 let suffix = if metadata.context_window_unverified {
3771 " (unverified)"
3772 } else {
3773 ""
3774 };
3775 parts.push(format!(
3776 "{} ctx{}",
3777 format_context_window(u64::from(context_window)),
3778 suffix
3779 ));
3780 }
3781 }
3782
3783 if let Some(max_output) = metadata.max_output {
3784 let suffix = if metadata.max_output_unverified {
3785 " (assumed floor)"
3786 } else {
3787 ""
3788 };
3789 parts.push(format!(
3790 "{} out{}",
3791 format_context_window(u64::from(max_output)),
3792 suffix
3793 ));
3794 }
3795
3796 match metadata.tool_calls {
3797 Some(true) => parts.push("tools".to_string()),
3798 Some(false) => parts.push("no tools".to_string()),
3799 None => {}
3800 }
3801
3802 if metadata.reasoning {
3803 parts.push("reasoning".to_string());
3804 }
3805
3806 match metadata.vision {
3807 SupportState::Supported => parts.push("vision".to_string()),
3808 SupportState::Unsupported => parts.push("no vision".to_string()),
3809 SupportState::Unknown => {}
3810 }
3811
3812 match &metadata.pricing {
3813 PickerPricing::Unavailable => {}
3814 PickerPricing::Known(label) => parts.push(label.clone()),
3815 PickerPricing::Unknown => parts.push("price unknown".to_string()),
3816 }
3817 let provider_live_source = matches!(metadata.source.as_ref(), Some(CatalogSource::Live { .. }));
3818 match metadata.source.as_ref() {
3819 Some(CatalogSource::Live { .. }) => {
3820 parts.push(provider_catalog_source_label(provider_catalog_receipt))
3821 }
3822 Some(CatalogSource::ModelsDevLive { .. }) => parts.push("live".to_string()),
3823 Some(CatalogSource::Bundled | CatalogSource::CodewhaleBundled { .. }) => {
3824 parts.push("bundled".to_string())
3825 }
3826 Some(CatalogSource::CloudFacts { .. }) => parts.push("signed facts".to_string()),
3827 Some(CatalogSource::ConfigOverride | CatalogSource::UserOverride) => {
3828 parts.push("user declared (unverified)".to_string())
3829 }
3830 None => {}
3831 }
3832 if !provider_live_source
3833 && let Some((CatalogStatus::Failed { reason }, _)) = provider_catalog_receipt
3834 {
3835 parts.push(format!(
3836 "refresh failed ({})",
3837 catalog_refresh_error_label(*reason)
3838 ));
3839 }
3840 if provider == Some(ProviderKind::OpenaiCodex) {
3841 parts.push(match codex_freshness {
3842 Some(freshness) => freshness.picker_label().to_string(),
3843 None => "custom · OAuth roster unconfirmed".to_string(),
3844 });
3845 }
3846
3847 if parts.is_empty() {
3848 "provider model".to_string()
3849 } else {
3850 parts.join(" · ")
3851 }
3852 }
3853
3854 fn provider_catalog_source_label(receipt: Option<&(CatalogStatus, bool)>) -> String {
3855 let Some((status, endpoint_matches)) = receipt else {
3856 return "catalog freshness unknown".to_string();
3857 };
3858 if !endpoint_matches {
3859 return "catalog from different endpoint".to_string();
3860 }
3861 match status {
3862 CatalogStatus::Fresh => "live".to_string(),
3863 CatalogStatus::Stale { age_secs } => {
3864 let age_hours = age_secs.saturating_add(3_599) / 3_600;
3865 format!("stale catalog ({age_hours}h)")
3866 }
3867 CatalogStatus::Failed { reason } => {
3868 format!("refresh failed ({})", catalog_refresh_error_label(*reason))
3869 }
3870 CatalogStatus::Unknown => "catalog freshness unknown".to_string(),
3871 }
3872 }
3873
3874 fn catalog_refresh_error_label(error: CatalogRefreshError) -> &'static str {
3875 match error {
3876 CatalogRefreshError::Unauthorized => "unauthorized",
3877 CatalogRefreshError::Forbidden => "forbidden",
3878 CatalogRefreshError::NotFound => "not found",
3879 CatalogRefreshError::RateLimited => "rate limited",
3880 CatalogRefreshError::InvalidResponse => "invalid response",
3881 CatalogRefreshError::EmptyList => "empty list",
3882 CatalogRefreshError::Network => "network error",
3883 }
3884 }
3885
3886 impl ModelPickerView {
3887 /// Rebuild model rows from a fresh app/config snapshot (readiness + catalog).
3888 pub fn re_resolve_from_app(&mut self, app: &App, config: &Config) {
3889 self.theme = app.ui_theme;
3890 let selected = self
3891 .visible_model_rows()
3892 .get(self.selected_model_idx)
3893 .map(|row| {
3894 (
3895 row_provider_identity(row).map(str::to_string),
3896 row.id.clone(),
3897 )
3898 });
3899 self.provider_health = app.provider_health.clone();
3900 self.route_config = config.clone();
3901 self.pinned_models = picker_pins_for_app(app);
3902 self.model_rows = picker_model_rows_for_app(app, config);
3903 self.apply_fleet_route_rows(app, config);
3904 *self.projection.get_mut() = None;
3905 self.last_mouse_selected = None;
3906 self.configured_providers = configured_providers(config)
3907 .into_iter()
3908 .filter(|identity| Some(identity) != self.initial_identity.as_ref())
3909 .collect();
3910 // Re-anchor to the same exact provider/model after pin sorting changes;
3911 // preserving only the numeric index can select a different model.
3912 let reanchored = selected.and_then(|(provider, model)| {
3913 self.visible_model_rows().iter().position(|row| {
3914 row.id == model && row_provider_identity(row).map(str::to_owned) == provider
3915 })
3916 });
3917 if let Some(position) = reanchored {
3918 self.selected_model_idx = position;
3919 return;
3920 }
3921 // Keep selection stable when the row still exists.
3922 let visible_len = self.visible_model_rows().len();
3923 if self.selected_model_idx >= visible_len + usize::from(self.show_custom_model_row) {
3924 self.selected_model_idx = visible_len.saturating_sub(1);
3925 }
3926 }
3927 }
3928
3929 impl ModelPickerView {
3930 /// Exact route of the highlighted catalog row: the target of ⇧P, ⇧F and
3931 /// Alt+↑↓. `None` on provider-less rows (`auto`) and the custom row.
3932 fn highlighted_route(&self) -> Option<(ProviderKind, Option<String>, String)> {
3933 let rows = self.visible_model_rows();
3934 let row = rows.get(self.selected_model_idx)?;
3935 Some((row.provider?, row.provider_identity.clone(), row.id.clone()))
3936 }
3937
3938 fn emit_pin_move(&self, delta: isize) -> ViewAction {
3939 let Some((provider, provider_id, model)) = self.highlighted_route() else {
3940 return ViewAction::None;
3941 };
3942 ViewAction::Emit(ViewEvent::ModelPickerMovePin {
3943 provider,
3944 provider_id,
3945 model,
3946 delta,
3947 })
3948 }
3949
3950 /// Show an action receipt inside the picker (see `notice`).
3951 pub fn set_notice(&mut self, text: String, level: StatusToastLevel) {
3952 self.notice = Some((text, level));
3953 }
3954 }
3955
3956 impl ModalView for ModelPickerView {
3957 fn kind(&self) -> ModalKind {
3958 ModalKind::ModelPicker
3959 }
3960
3961 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
3962 self
3963 }
3964
3965 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
3966 self.last_mouse_selected = None;
3967 self.hovered_row = None;
3968 self.notice = None;
3969 // Movement keys come from the shared vocabulary (#6290); the match
3970 // below owns only the picker's own verbs. The live filter means the
3971 // typing-safe set — no letter aliases to eat the query.
3972 if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key)
3973 && self.apply_motion(motion)
3974 {
3975 return ViewAction::None;
3976 }
3977 match key.code {
3978 KeyCode::Char('s' | 'S') if key.modifiers == KeyModifiers::CONTROL => {
3979 self.cycle_sort();
3980 ViewAction::None
3981 }
3982 // Esc carries the browsing context out so the next open can
3983 // restore it (#4109 picker memory).
3984 KeyCode::Esc if !self.query.is_empty() => {
3985 self.update_query(String::new());
3986 ViewAction::None
3987 }
3988 KeyCode::Esc if self.purpose != ModelPickerPurpose::Session => {
3989 let editor_id = match self.purpose {
3990 ModelPickerPurpose::FleetRoute { editor_id, .. }
3991 | ModelPickerPurpose::FleetProfileRoute { editor_id, .. } => editor_id,
3992 ModelPickerPurpose::Session => unreachable!(),
3993 };
3994 ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id })
3995 }
3996 KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
3997 catalog_view: self.view.browses_all_providers(),
3998 view: self.view.memory_name().to_string(),
3999 selected_row_id: {
4000 let rows = self.visible_model_rows();
4001 rows.get(self.selected_model_idx).map(|row| row.id.clone())
4002 },
4003 }),
4004 KeyCode::Enter if self.model_row_count() == 0 => ViewAction::None,
4005 KeyCode::Enter if !self.selected_model_is_selectable() => {
4006 // Never silently ignore Enter on locked models — surface the
4007 // readiness reason and offer provider setup.
4008 self.explain_unselectable_selection()
4009 }
4010 KeyCode::Enter => ViewAction::EmitAndClose(self.build_apply_event(false)),
4011 // Shift+D makes the visible provider/model pair the startup
4012 // default. Plain Enter deliberately stays session-local, so a
4013 // one-off route comparison cannot silently change the next launch.
4014 // Exactly Shift: this chord persists the next launch's route, so
4015 // Ctrl/Alt/Super+Shift+D must not save it by accident (U07-12).
4016 KeyCode::Char(ch)
4017 if key.modifiers == KeyModifiers::SHIFT
4018 && self.query.is_empty()
4019 && ch.eq_ignore_ascii_case(&'d')
4020 && self.selected_model_is_selectable() =>
4021 {
4022 ViewAction::EmitAndClose(self.build_apply_event(true))
4023 }
4024 // Only an empty query explains a locked row. While searching,
4025 // ⇧D is query text: it used to open provider auth for a row that
4026 // was not locked at all (#6500).
4027 KeyCode::Char(ch)
4028 if key.modifiers == KeyModifiers::SHIFT
4029 && self.query.is_empty()
4030 && ch.eq_ignore_ascii_case(&'d') =>
4031 {
4032 self.explain_unselectable_selection()
4033 }
4034 // ⇧P / ⇧F act on the highlighted row, searching or not: a search
4035 // is how a model is found, and the verbs used to become query
4036 // text the moment one was typed (#6500). The filter ignores case,
4037 // so only a custom id being typed (no catalog row highlighted)
4038 // still receives the capital letter. Some terminals report the
4039 // chord as lowercase + SHIFT, hence `eq_ignore_ascii_case`.
4040 KeyCode::Char(ch)
4041 if key.modifiers == KeyModifiers::SHIFT
4042 && ch.eq_ignore_ascii_case(&'p')
4043 && (self.query.is_empty() || self.highlighted_route().is_some()) =>
4044 {
4045 let Some((provider, provider_id, model)) = self.highlighted_route() else {
4046 return ViewAction::None;
4047 };
4048 ViewAction::Emit(ViewEvent::ModelPickerTogglePin {
4049 provider,
4050 provider_id,
4051 model,
4052 })
4053 }
4054 KeyCode::Char(ch)
4055 if key.modifiers == KeyModifiers::SHIFT
4056 && ch.eq_ignore_ascii_case(&'f')
4057 && self.purpose == ModelPickerPurpose::Session
4058 && (self.query.is_empty() || self.highlighted_route().is_some()) =>
4059 {
4060 let Some((provider, provider_id, model)) = self.highlighted_route() else {
4061 return ViewAction::None;
4062 };
4063 ViewAction::Emit(ViewEvent::ModelPickerToggleFleet {
4064 provider,
4065 provider_id,
4066 model,
4067 })
4068 }
4069 KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
4070 self.emit_pin_move(-1)
4071 }
4072 KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
4073 self.emit_pin_move(1)
4074 }
4075 // Cycle catalog views (#4115) without shadowing a typed provider
4076 // name such as `anthropic` or `azure`.
4077 KeyCode::Char('A') if key.modifiers == KeyModifiers::SHIFT && self.query.is_empty() => {
4078 self.toggle_view();
4079 ViewAction::None
4080 }
4081 KeyCode::Char(ch)
4082 if self.focus == Pane::Model
4083 && !key
4084 .modifiers
4085 .contains(crossterm::event::KeyModifiers::CONTROL) =>
4086 {
4087 let mut query = self.query.clone();
4088 query.push(ch);
4089 self.update_query(query);
4090 ViewAction::None
4091 }
4092 KeyCode::Backspace if self.focus == Pane::Model && !self.query.is_empty() => {
4093 let mut query = self.query.clone();
4094 query.pop();
4095 self.update_query(query);
4096 ViewAction::None
4097 }
4098 // Explicit readiness + catalog refresh (safe, non-destructive).
4099 // Plain `r` remains a route-search character.
4100 KeyCode::Char('r') | KeyCode::Char('R')
4101 if key.modifiers == crossterm::event::KeyModifiers::CONTROL =>
4102 {
4103 ViewAction::Emit(ViewEvent::ModelPickerRefresh)
4104 }
4105 _ => ViewAction::None,
4106 }
4107 }
4108
4109 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
4110 let over_catalog = self
4111 .catalog_action_hitbox
4112 .borrow()
4113 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()));
4114 if mouse.kind == MouseEventKind::Moved {
4115 self.catalog_action_hovered = over_catalog;
4116 }
4117 if over_catalog && mouse.kind == MouseEventKind::Down(MouseButton::Left) {
4118 self.toggle_view();
4119 self.catalog_action_hovered = false;
4120 self.last_mouse_selected = None;
4121 return ViewAction::None;
4122 }
4123 match mouse.kind {
4124 MouseEventKind::Moved => {
4125 self.hovered_row =
4126 self.row_hitboxes
4127 .borrow()
4128 .iter()
4129 .find_map(|(rect, pane, idx)| {
4130 rect.contains((mouse.column, mouse.row).into())
4131 .then_some((*pane, *idx))
4132 });
4133 ViewAction::None
4134 }
4135 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
4136 self.last_mouse_selected = None;
4137 self.hovered_row = None;
4138 let pane = self.pane_hitboxes.borrow().iter().find_map(|(rect, pane)| {
4139 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
4140 .then_some(*pane)
4141 });
4142 let Some(pane) = pane else {
4143 return ViewAction::None;
4144 };
4145 self.focus = pane;
4146 if mouse.kind == MouseEventKind::ScrollUp {
4147 self.move_up();
4148 } else {
4149 self.move_down();
4150 }
4151 ViewAction::None
4152 }
4153 MouseEventKind::Down(MouseButton::Left) => {
4154 let column = self
4155 .column_hitboxes
4156 .borrow()
4157 .iter()
4158 .find_map(|(rect, column)| {
4159 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
4160 .then_some(*column)
4161 });
4162 if let Some(column) = column {
4163 // Sorting acts on the Model pane, so the header click
4164 // moves focus there too — otherwise the next keystroke or
4165 // wheel event edits the pane that previously had focus.
4166 self.focus = Pane::Model;
4167 self.sort_column(column);
4168 return ViewAction::None;
4169 }
4170 let clicked = self
4171 .row_hitboxes
4172 .borrow()
4173 .iter()
4174 .find_map(|(rect, pane, idx)| {
4175 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
4176 .then_some((*pane, *idx))
4177 });
4178 let Some((pane, idx)) = clicked else {
4179 return ViewAction::None;
4180 };
4181 let apply = self.last_mouse_selected == Some((pane, idx))
4182 && self.focus == pane
4183 && match pane {
4184 Pane::Model => self.selected_model_idx == idx,
4185 Pane::Effort => self.selected_effort_idx == idx,
4186 };
4187 self.focus = pane;
4188 match pane {
4189 Pane::Model => {
4190 self.selected_model_idx = idx.min(self.model_row_count().saturating_sub(1));
4191 self.select_effort_for_current_model();
4192 }
4193 Pane::Effort => {
4194 self.selected_effort_idx =
4195 idx.min(self.current_efforts().len().saturating_sub(1));
4196 self.selected_effort_request = self.resolved_effort();
4197 }
4198 }
4199 self.last_mouse_selected = Some((pane, idx));
4200 if apply && self.selected_model_is_selectable() {
4201 ViewAction::EmitAndClose(self.build_apply_event(false))
4202 } else if apply {
4203 self.explain_unselectable_selection()
4204 } else {
4205 ViewAction::None
4206 }
4207 }
4208 _ => ViewAction::None,
4209 }
4210 }
4211
4212 fn render(&self, area: Rect, buf: &mut Buffer) {
4213 self.render_route(area, buf);
4214 }
4215 }
4216
4217 impl ModelPickerView {
4218 fn render_route(&self, area: Rect, buf: &mut Buffer) {
4219 self.row_hitboxes.borrow_mut().clear();
4220 self.column_hitboxes.borrow_mut().clear();
4221 self.pane_hitboxes.borrow_mut().clear();
4222 *self.catalog_action_hitbox.borrow_mut() = None;
4223 let view_action: std::borrow::Cow<'static, str> = match self.view {
4224 // The default view is a short list; say how much sits behind it.
4225 ModelListView::Configured => format!(
4226 "{} ({})",
4227 tr(self.locale, MessageId::RouteBrowseCatalog),
4228 self.model_rows
4229 .iter()
4230 .filter(|row| row.provider.is_some())
4231 .count()
4232 )
4233 .into(),
4234 other => other.next().title_label().into(),
4235 };
4236 let title = self
4237 .assignment_context
4238 .as_ref()
4239 .map(|(role, _)| format!("Model · {role}"))
4240 .unwrap_or_else(|| {
4241 tr(self.locale, MessageId::RouteSurfaceTitle)
4242 .replace("{view}", self.view.title_label())
4243 });
4244 // The catalog is a visible action on the existing title rail, with
4245 // no extra row taken from short terminals. Keep the shortcut too.
4246 let action_label = crate::tui::ui_text::semantic_truncate(
4247 &view_action,
4248 usize::from(area.width.saturating_sub(20)),
4249 );
4250 let action_width = unicode_width::UnicodeWidthStr::width(action_label.as_str()) as u16;
4251 let show_action = area.width >= 28
4252 && area.height > 0
4253 && (self.assignment_context.is_none()
4254 || unicode_width::UnicodeWidthStr::width(title.as_str())
4255 + usize::from(action_width)
4256 + 8
4257 <= usize::from(area.width));
4258 let title = if show_action {
4259 crate::tui::ui_text::semantic_truncate(
4260 &title,
4261 usize::from(area.width.saturating_sub(action_width + 8)),
4262 )
4263 } else {
4264 title
4265 };
4266 let inner = render_underwater_surface(area, buf, title);
4267 if show_action {
4268 let action = Rect::new(
4269 inner.right().saturating_sub(action_width),
4270 area.y + u16::from(area.height >= 24),
4271 action_width,
4272 1,
4273 );
4274 *self.catalog_action_hitbox.borrow_mut() = Some(action);
4275 Paragraph::new(action_label)
4276 .style(if self.catalog_action_hovered {
4277 menu_style::hovered_row_style()
4278 } else {
4279 Style::default().fg(palette::WHALE_ACTION).underlined()
4280 })
4281 .render(action, buf);
4282 }
4283 // ⇧A and ⇧D are query text while a search is typed; advertise them
4284 // only when they act.
4285 let searching = !self.query.is_empty();
4286 let mut footer_hints = vec![
4287 ActionHint::new("↑↓", tr(self.locale, MessageId::PickerActionMove)),
4288 ActionHint::new("Tab", tr(self.locale, MessageId::PickerActionSwitch)),
4289 ActionHint::new(
4290 tr(self.locale, MessageId::RouteActionType),
4291 tr(self.locale, MessageId::RouteActionSearchAnyModel),
4292 ),
4293 ActionHint::new("Enter", tr(self.locale, self.apply_action_id())),
4294 ];
4295 if !searching {
4296 footer_hints.push(ActionHint::new("⇧A", view_action));
4297 }
4298 if inner.height >= 16 {
4299 footer_hints.push(ActionHint::new(
4300 "Ctrl+S",
4301 tr(self.locale, MessageId::SessionsActionSort),
4302 ));
4303 }
4304 if !self.can_edit_effort() {
4305 footer_hints.remove(1);
4306 }
4307 // A Fleet row has no startup default to save; the chord is a
4308 // session-route action only.
4309 if self.purpose == ModelPickerPurpose::Session && inner.height >= 16 && !searching {
4310 footer_hints.insert(
4311 4,
4312 ActionHint::new(
4313 "⇧D",
4314 tr(self.locale, MessageId::PickerActionSetStartupDefault),
4315 ),
4316 );
4317 }
4318 // Keep compact route modals focused on the core browse/apply actions;
4319 // wider shells have room to disclose the pin action too.
4320 // ⇧P / ⇧F act only on a highlighted catalog route; without one
4321 // (typing a custom id) they are query text, so they are not offered.
4322 if inner.width >= 72 && inner.height >= 16 && self.highlighted_route().is_some() {
4323 if self.purpose == ModelPickerPurpose::Session {
4324 footer_hints.push(ActionHint::new(
4325 "⇧F",
4326 tr(self.locale, MessageId::PickerActionFleet),
4327 ));
4328 }
4329 footer_hints.push(ActionHint::new(
4330 "⇧P",
4331 tr(self.locale, MessageId::PickerActionPin),
4332 ));
4333 }
4334 footer_hints.push(ActionHint::new(
4335 "Esc",
4336 tr(self.locale, MessageId::PickerActionCancel),
4337 ));
4338 let content = render_modal_footer(inner, buf, &footer_hints);
4339
4340 let shell = ratatui::layout::Layout::default()
4341 .direction(ratatui::layout::Direction::Vertical)
4342 .constraints([
4343 ratatui::layout::Constraint::Length(1),
4344 ratatui::layout::Constraint::Min(1),
4345 ])
4346 .split(content);
4347 Paragraph::new(Line::from(vec![
4348 Span::styled(
4349 self.assignment_context
4350 .as_ref()
4351 .map(|(_, scope)| format!("{scope} · "))
4352 .unwrap_or_else(|| {
4353 format!("{} ", tr(self.locale, MessageId::RouteProviderLabel))
4354 }),
4355 Style::default().fg(palette::TEXT_MUTED),
4356 ),
4357 Span::styled(
4358 self.resolved_admitted_identity()
4359 .as_ref()
4360 .or(self.initial_identity.as_ref())
4361 .map_or("unavailable", |identity| {
4362 identity
4363 .compatibility()
4364 .map_or(identity.key.as_str(), |row| row.label)
4365 })
4366 .to_string(),
4367 Style::default().fg(palette::TEXT_PRIMARY),
4368 ),
4369 Span::styled(
4370 self.visible_model_rows()
4371 .get(self.selected_model_idx)
4372 .and_then(|row| row.blocked_reason.as_deref())
4373 .map(|reason| format!(" · ! {reason}"))
4374 .unwrap_or_default(),
4375 Style::default().fg(palette::STATUS_WARNING),
4376 ),
4377 Span::styled(
4378 self.notice
4379 .as_ref()
4380 .map(|(text, _)| format!(" · {text}"))
4381 .unwrap_or_default(),
4382 self.notice
4383 .as_ref()
4384 .map(|(_, level)| palette::chrome_style(&self.theme, level.ink()))
4385 .unwrap_or_default(),
4386 ),
4387 Span::styled(
4388 if self.assignment_context.is_some() {
4389 ""
4390 } else {
4391 catalog_freshness_title_suffix()
4392 },
4393 Style::default().fg(palette::TEXT_MUTED),
4394 ),
4395 ]))
4396 .render(shell[0], buf);
4397
4398 let mut layout = widen_model_pane(ListDetailLayout::split(shell[1], 24));
4399 if !self.can_edit_effort() {
4400 layout.list = shell[1];
4401 } else if layout.stacked && shell[1].height < 12 {
4402 let model_height = if self.focus == Pane::Model {
4403 shell[1].height.saturating_sub(1)
4404 } else {
4405 u16::from(shell[1].height > 0)
4406 };
4407 layout.list = Rect::new(shell[1].x, shell[1].y, shell[1].width, model_height);
4408 layout.detail = Rect::new(
4409 shell[1].x,
4410 shell[1].y + model_height,
4411 shell[1].width,
4412 shell[1].height.saturating_sub(model_height),
4413 );
4414 }
4415
4416 self.ensure_projection();
4417 let projection = self.projection.borrow();
4418 let model_rows = &projection.as_ref().unwrap().rows;
4419 let model_title = if self.query.trim().is_empty() {
4420 format!("Model · {}", self.view.title_label())
4421 } else {
4422 format!("Model: {}", self.query.trim())
4423 };
4424 self.render_pane(
4425 layout.list,
4426 buf,
4427 &model_title,
4428 model_rows,
4429 PaneRenderState {
4430 pane: Pane::Model,
4431 selected: self.selected_model_idx,
4432 focused: self.focus == Pane::Model,
4433 },
4434 );
4435
4436 if !self.can_edit_effort() {
4437 return;
4438 }
4439 let effort_provider = self.resolved_provider().unwrap_or(self.initial_provider);
4440 let current_efforts = self.current_efforts();
4441 let selected_effort_idx = self
4442 .selected_effort_idx
4443 .min(current_efforts.len().saturating_sub(1));
4444 let effort_rows: Vec<PaneRow> = current_efforts
4445 .iter()
4446 .map(|effort| {
4447 let label = effort
4448 .display_label_for_provider(effort_provider)
4449 .to_string();
4450 let hint = match effort {
4451 ReasoningEffort::Auto => "choose per turn".to_string(),
4452 ReasoningEffort::Off => "no extra reasoning".to_string(),
4453 ReasoningEffort::Minimal => "minimal reasoning".to_string(),
4454 ReasoningEffort::Low => "lighter reasoning".to_string(),
4455 ReasoningEffort::Medium => "balanced reasoning".to_string(),
4456 ReasoningEffort::High => "deeper reasoning".to_string(),
4457 ReasoningEffort::XHigh => "extra-high reasoning".to_string(),
4458 ReasoningEffort::Ultra => "ultra reasoning".to_string(),
4459 ReasoningEffort::Max => "maximum reasoning".to_string(),
4460 };
4461 PaneRow::effort(label, hint)
4462 })
4463 .collect();
4464 self.render_pane(
4465 layout.detail,
4466 buf,
4467 "Thinking",
4468 &effort_rows,
4469 PaneRenderState {
4470 pane: Pane::Effort,
4471 selected: selected_effort_idx,
4472 focused: self.focus == Pane::Effort,
4473 },
4474 );
4475 }
4476 }
4477
4478 /// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared
4479 /// vocabulary instead of wrapping (#6290).
4480 const MODEL_PAGE: usize = 5;
4481
4482 /// Previous index in a list that rotates: 0 wraps to the last row.
4483 /// `count` must be non-zero.
4484 fn wrapping_prev(index: usize, count: usize) -> usize {
4485 if index == 0 {
4486 count - 1
4487 } else {
4488 (index - 1).min(count - 1)
4489 }
4490 }
4491
4492 /// Next index in a list that rotates: the last row wraps to 0.
4493 /// `count` must be non-zero.
4494 fn wrapping_next(index: usize, count: usize) -> usize {
4495 if index + 1 >= count { 0 } else { index + 1 }
4496 }
4497
4498 pub(crate) fn picker_efforts_for_route(
4499 provider: ProviderKind,
4500 base_url: &str,
4501 wire_model: &str,
4502 model_is_auto: bool,
4503 ) -> Vec<ReasoningEffort> {
4504 if model_is_auto {
4505 return AUTO_MODEL_PICKER_EFFORTS.to_vec();
4506 }
4507 distinct_effective_efforts(
4508 route_picker_efforts(provider, base_url, wire_model),
4509 provider,
4510 base_url,
4511 wire_model,
4512 )
4513 }
4514
4515 /// The tier a concrete route receives for `effort`: the route-constrained
4516 /// receipt tier when the route's dialect is narrower than the generic
4517 /// normalization (Z.ai GLM, Kimi Code K3), otherwise
4518 /// [`ReasoningEffort::normalize_for_route`]. This is the effective tier the
4519 /// effort status line and Work receipts report, so the ladder and the cycler
4520 /// dedupe against the same value the operator sees.
4521 pub(crate) fn effective_tier_for_route(
4522 effort: ReasoningEffort,
4523 provider: ProviderKind,
4524 base_url: &str,
4525 wire_model: &str,
4526 ) -> ReasoningEffort {
4527 match crate::work_graph::constrained_effective_reasoning_for_route(
4528 effort.into(),
4529 provider,
4530 base_url,
4531 wire_model,
4532 )
4533 .map(crate::reasoning_preference::EffectiveReasoningEffort::from)
4534 {
4535 Some(crate::reasoning_preference::EffectiveReasoningEffort::Tier(tier)) => tier,
4536 _ => effort.normalize_for_route(provider, base_url, wire_model),
4537 }
4538 }
4539
4540 /// Drop rungs that resolve to the same effective tier as another rung on the
4541 /// route (#6650). The picker and the Ctrl+T cycler walk this ladder, so a rung
4542 /// whose effective tier is already offered would be a row that changes
4543 /// nothing and a key press that does nothing — DeepSeek's `medium` lands on
4544 /// `high`, Z.ai GLM-5.2's `low` lands on `high`, an always-thinking route's
4545 /// `off` lands on its lowest tier, and a catalog `thinking: disabled` value
4546 /// the effort dialect cannot express lands on the catalog default. When two
4547 /// rungs collide, the one whose own value is the effective tier wins, so the
4548 /// row names what the route will receive. `Auto` always stays: it is the
4549 /// "leave it to the route" preference, not a tier, and is displayed as its
4550 /// own state. Routes whose effective tier cannot be proven (a custom endpoint,
4551 /// an enabled-but-untiered toggle) keep their rows.
4552 fn distinct_effective_efforts(
4553 efforts: Vec<ReasoningEffort>,
4554 provider: ProviderKind,
4555 base_url: &str,
4556 wire_model: &str,
4557 ) -> Vec<ReasoningEffort> {
4558 let effective =
4559 |effort: ReasoningEffort| effective_tier_for_route(effort, provider, base_url, wire_model);
4560 let mut seen = Vec::with_capacity(efforts.len());
4561 let mut distinct = Vec::with_capacity(efforts.len());
4562 for &effort in &efforts {
4563 if effort == ReasoningEffort::Auto {
4564 distinct.push(effort);
4565 continue;
4566 }
4567 let tier = effective(effort);
4568 if (tier != effort && efforts.contains(&tier)) || seen.contains(&tier) {
4569 continue;
4570 }
4571 seen.push(tier);
4572 distinct.push(effort);
4573 }
4574 distinct
4575 }
4576
4577 fn route_picker_efforts(
4578 provider: ProviderKind,
4579 base_url: &str,
4580 wire_model: &str,
4581 ) -> Vec<ReasoningEffort> {
4582 // Exact-route overrides still win over catalog metadata: Kimi Code K3 and
4583 // OpenAI Codex have wire dialects the generic Models.dev shape does not
4584 // fully describe.
4585 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
4586 return KIMI_CODE_K3_PICKER_EFFORTS.to_vec();
4587 }
4588 if provider == ProviderKind::OpenaiCodex {
4589 // The official account list publishes IDs and display labels, not
4590 // effort tiers. Keep the existing wire-compatible route ladder.
4591 return CODEX_PICKER_EFFORTS.to_vec();
4592 }
4593 if let Some(catalog_efforts) = catalog_picker_efforts(provider, wire_model) {
4594 return catalog_efforts;
4595 }
4596 if matches!(provider, crate::config::ProviderKind::Deepseek) {
4597 return DEEPSEEK_PICKER_EFFORTS.to_vec();
4598 }
4599 DEFAULT_PICKER_EFFORTS.to_vec()
4600 }
4601
4602 /// Build thinking-tier rows from Models.dev `reasoning_options` when present.
4603 ///
4604 /// Expected shape (already parsed onto the catalog offering):
4605 /// `[{ "type": "effort", "values": ["high", "max"] }]`.
4606 /// Non-effort option types (e.g. MiniMax `thinking`) are mapped when their
4607 /// values collapse cleanly onto our tier vocabulary; unknown values are
4608 /// skipped. Returns `None` when the catalog has no usable effort list so the
4609 /// caller can keep the provider default rather than inventing tiers.
4610 fn catalog_picker_efforts(
4611 provider: ProviderKind,
4612 wire_model: &str,
4613 ) -> Option<Vec<ReasoningEffort>> {
4614 let offering = catalog_offering_for_model(provider, wire_model)?;
4615 let mut efforts = Vec::new();
4616 let mut saw_effort_list = false;
4617 let mut has_toggle = false;
4618 for option in &offering.reasoning_options {
4619 let option_type = option
4620 .get("type")
4621 .and_then(|value| value.as_str())
4622 .unwrap_or("")
4623 .to_ascii_lowercase();
4624 if option_type == "toggle" {
4625 has_toggle = true;
4626 continue;
4627 }
4628 // Prefer explicit effort lists; also accept thinking-mode lists whose
4629 // values map onto our tiers (adaptive→auto, disabled→off, always_on→max).
4630 if option_type != "effort" && option_type != "thinking" {
4631 continue;
4632 }
4633 let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
4634 continue;
4635 };
4636 saw_effort_list = true;
4637 for value in values {
4638 let Some(raw) = value.as_str() else {
4639 continue;
4640 };
4641 if let Some(effort) = catalog_effort_value(raw)
4642 && !efforts.contains(&effort)
4643 {
4644 efforts.push(effort);
4645 }
4646 }
4647 }
4648 if !saw_effort_list || efforts.is_empty() {
4649 return None;
4650 }
4651 // A Models.dev `toggle` beside the ladder means reasoning can also be
4652 // switched off (#6396).
4653 if has_toggle && !efforts.contains(&ReasoningEffort::Off) {
4654 efforts.insert(0, ReasoningEffort::Off);
4655 }
4656 // Always offer Auto when the catalog published discrete tiers so the
4657 // operator can still leave the choice to the route default. Do not invent
4658 // Off unless the catalog said so — some models are always-on.
4659 if !efforts.contains(&ReasoningEffort::Auto) {
4660 efforts.insert(0, ReasoningEffort::Auto);
4661 }
4662 Some(efforts)
4663 }
4664
4665 fn catalog_effort_value(raw: &str) -> Option<ReasoningEffort> {
4666 match raw.trim().to_ascii_lowercase().as_str() {
4667 "off" | "disabled" | "false" => Some(ReasoningEffort::Off),
4668 "none" => Some(ReasoningEffort::Off), // Muse "none" maps to Off in our enum but display as "none"
4669 "minimal" | "minimum" => Some(ReasoningEffort::Minimal),
4670 "low" | "light" => Some(ReasoningEffort::Low),
4671 "medium" | "mid" => Some(ReasoningEffort::Medium),
4672 "high" => Some(ReasoningEffort::High),
4673 "xhigh" => Some(ReasoningEffort::XHigh),
4674 "ultra" | "ultracode" => Some(ReasoningEffort::Ultra),
4675 "max" | "maximum" => Some(ReasoningEffort::Max),
4676 "auto" | "automatic" | "adaptive" => Some(ReasoningEffort::Auto),
4677 "always_on" | "always-on" => Some(ReasoningEffort::Max),
4678 _ => None,
4679 }
4680 }
4681
4682 fn normalize_picker_effort(
4683 effort: ReasoningEffort,
4684 provider: ProviderKind,
4685 base_url: &str,
4686 wire_model: &str,
4687 model_is_auto: bool,
4688 ) -> ReasoningEffort {
4689 let normalized = if model_is_auto {
4690 effort
4691 } else {
4692 effort.normalize_for_route(provider, base_url, wire_model)
4693 };
4694 let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
4695 if efforts.contains(&normalized) {
4696 return normalized;
4697 }
4698 // Catalog-driven lists may keep Low/Medium that route normalization would
4699 // otherwise collapse. Prefer the operator's exact choice when the picker
4700 // still shows it.
4701 if efforts.contains(&effort) {
4702 return effort;
4703 }
4704 default_picker_effort(provider, &efforts)
4705 }
4706
4707 fn default_picker_effort(provider: ProviderKind, efforts: &[ReasoningEffort]) -> ReasoningEffort {
4708 let preferred = if provider == ProviderKind::OpenaiCodex {
4709 ReasoningEffort::Medium
4710 } else {
4711 ReasoningEffort::High
4712 };
4713 if efforts.contains(&preferred) {
4714 preferred
4715 } else {
4716 efforts
4717 .iter()
4718 .copied()
4719 .find(|effort| *effort != ReasoningEffort::Auto && *effort != ReasoningEffort::Off)
4720 .or_else(|| efforts.first().copied())
4721 .unwrap_or(preferred)
4722 }
4723 }
4724
4725 fn default_picker_effort_idx(
4726 provider: ProviderKind,
4727 base_url: &str,
4728 wire_model: &str,
4729 model_is_auto: bool,
4730 ) -> usize {
4731 let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
4732 let default_effort = default_picker_effort(provider, &efforts);
4733 efforts
4734 .iter()
4735 .position(|effort| *effort == default_effort)
4736 .unwrap_or(0)
4737 }
4738
4739 #[cfg(test)]
4740 mod tests {
4741 use super::*;
4742
4743 #[test]
4744 fn picker_ladder_drops_rungs_that_resolve_to_an_offered_tier() {
4745 use ReasoningEffort::*;
4746 // DeepSeek collapses minimal/medium/xhigh/ultra onto low/high/max, so
4747 // a catalog that published every spelling still shows one row per
4748 // wire tier (#6650).
4749 assert_eq!(
4750 distinct_effective_efforts(
4751 vec![Auto, Off, Minimal, Low, Medium, High, XHigh, Ultra, Max],
4752 ProviderKind::Deepseek,
4753 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
4754 "deepseek-v4.1-flash",
4755 ),
4756 vec![Auto, Off, Low, High, Max]
4757 );
4758 // When the alias comes first, the rung that names the tier still wins.
4759 assert_eq!(
4760 distinct_effective_efforts(
4761 vec![Auto, Medium, High, Max],
4762 ProviderKind::Deepseek,
4763 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
4764 "deepseek-v4.1-flash",
4765 ),
4766 vec![Auto, High, Max]
4767 );
4768 }
4769
4770 #[test]
4771 fn configured_model_picker_and_runtime_share_exact_persisted_metadata() {
4772 let _env = crate::test_support::lock_test_env();
4773 let _catalog = crate::provider_lake::lock_live_snapshot();
4774 let mut config: Config = toml::from_str(include_str!(
4775 "../../../config/tests/fixtures/custom_models.toml"
4776 ))
4777 .unwrap();
4778 let id = "deepseek-v4.1-flash-expires-on-0910";
4779 let base = "https://models.example.test/v1";
4780 let metadata = effective_picker_metadata(&config, Some(ProviderKind::Deepseek), id);
4781 assert_eq!(metadata.display_name.as_deref(), Some("Temporary preview"));
4782 assert_eq!(metadata.context_window, Some(96000));
4783 assert_eq!(metadata.max_output, Some(8000));
4784 assert_eq!(metadata.tool_calls, Some(true));
4785 assert_eq!(metadata.source, Some(CatalogSource::ConfigOverride));
4786 let mut row = model_row(ProviderKind::Deepseek, true);
4787 row.id = id.into();
4788 row.metadata = metadata.clone();
4789 let chips = model_row_meta_chips(&row);
4790 assert_eq!(chips[0], "user declared (unverified)");
4791 assert!(chips.iter().any(|chip| chip.starts_with("estimate ")));
4792 assert!(fit_meta_chips(&chips, 30).starts_with("user declared"));
4793 assert!(
4794 render_picker_model_hint(id, Some(ProviderKind::Deepseek), &metadata, None, None)
4795 .contains("user declared")
4796 );
4797 assert!(
4798 crate::provider_lake::configured_catalog_models_for_route(
4799 &config,
4800 ProviderKind::Deepseek,
4801 "deepseek",
4802 base
4803 )
4804 .iter()
4805 .any(|model| model == id)
4806 );
4807 let route =
4808 crate::route_runtime::resolve_runtime_route(&config, ProviderKind::Deepseek, Some(id))
4809 .unwrap();
4810 assert_eq!(route.model, id);
4811 assert_eq!(Some(route.context_window.tokens), metadata.context_window);
4812 assert_eq!(
4813 route.context_window.source,
4814 crate::route_runtime::ContextWindowSource::UserDeclared
4815 );
4816 assert!(!route.context_window.source.is_verified());
4817 assert_eq!(
4818 route.candidate.capabilities().image_input,
4819 SupportState::Unknown
4820 );
4821 assert_eq!(
4822 route.candidate.capabilities().native_tool_calls,
4823 SupportState::Unknown
4824 );
4825 assert_eq!(
4826 crate::route_budget::route_output_limit_tokens(Some(route.candidate.limits())),
4827 metadata.max_output
4828 );
4829 // /load replaces the same metadata alongside the provider route.
4830 let mut reloaded = config.clone();
4831 reloaded.custom_models.as_mut().unwrap()[0].reasoning = None;
4832 reloaded.custom_models.as_mut().unwrap()[0].limit = None;
4833 reloaded.custom_models.as_mut().unwrap()[0].cost = None;
4834 reloaded.custom_models.as_mut().unwrap()[0].tool_call = None;
4835 reloaded.custom_models.as_mut().unwrap()[0].modalities = None;
4836 config.refresh_provider_routes_from(&reloaded);
4837 let unknown = effective_picker_metadata(&config, Some(ProviderKind::Deepseek), id);
4838 // An automatic request allowance is policy, not discovered metadata.
4839 // Its limits are covered by route_budget; the picker must stay unknown.
4840 assert_eq!(unknown.context_window, None);
4841 assert_eq!(unknown.max_output, None);
4842 assert_eq!(unknown.tool_calls, None);
4843 assert_eq!(unknown.vision, SupportState::Unknown);
4844 assert_eq!(unknown.pricing, PickerPricing::Unknown);
4845 row.metadata = unknown;
4846 assert!(model_row_meta_chips(&row).contains(&"reasoning unknown".to_string()));
4847 }
4848
4849 fn model_row(provider: ProviderKind, in_default_view: bool) -> ModelPickerRow {
4850 ModelPickerRow {
4851 id: "model".to_string(),
4852 provider: Some(provider),
4853 provider_identity: None,
4854 admitted_identity: (provider != ProviderKind::Custom)
4855 .then(|| Config::default().test_identity_for_kind(provider)),
4856 hint: String::new(),
4857 metadata: EffectivePickerMetadata::default(),
4858 selectable: true,
4859 blocked_reason: None,
4860 default_rank: in_default_view.then_some((DefaultSection::Current, 0)),
4861 credentialed: false,
4862 not_listed: None,
4863 }
4864 }
4865
4866 /// #6523 review: named custom providers are exact config keys, so a pin
4867 /// on `TeamA` must not mark the same model under `teama`; built-in slugs
4868 /// stay case-insensitive aliases.
4869 #[test]
4870 fn pin_matching_is_exact_for_named_custom_providers_only() {
4871 let pin = |provider: &str| PinnedModel {
4872 provider: provider.to_string(),
4873 model: "model".to_string(),
4874 label: None,
4875 };
4876 let custom = |identity: &str| ModelPickerRow {
4877 provider_identity: Some(identity.to_string()),
4878 ..model_row(ProviderKind::Custom, true)
4879 };
4880 assert!(pin_names_row(&pin("TeamA"), &custom("TeamA")));
4881 assert!(!pin_names_row(&pin("TeamA"), &custom("teama")));
4882 assert!(!pin_names_row(&pin("teama"), &custom("TeamA")));
4883
4884 let builtin = model_row(ProviderKind::Deepseek, true);
4885 assert!(pin_names_row(&pin("deepseek"), &builtin));
4886 assert!(pin_names_row(&pin("DeepSeek"), &builtin));
4887 }
4888
4889 /// The catalog sort ranks pins with the same exact match that marks them:
4890 /// a `TeamA` pin must not lift an unmarked `teama` row into the pinned
4891 /// block above the groups.
4892 #[test]
4893 fn catalog_sort_ranks_only_rows_the_pin_names() {
4894 let pins = vec![PinnedModel {
4895 provider: "TeamA".to_string(),
4896 model: "model".to_string(),
4897 label: None,
4898 }];
4899 let custom = |identity: &str| ModelPickerRow {
4900 provider_identity: Some(identity.to_string()),
4901 ..model_row(ProviderKind::Custom, true)
4902 };
4903 let owned = [
4904 model_row(ProviderKind::Deepseek, true),
4905 custom("teama"),
4906 custom("TeamA"),
4907 ];
4908 let mut rows: Vec<&ModelPickerRow> = owned.iter().collect();
4909 sort_model_rows_for_view(&mut rows, |row| *row, ModelListView::Catalog, &pins);
4910
4911 // Pinned `TeamA` leads; `teama` falls back to plain group order
4912 // (after `deepseek`) instead of riding the pin into the top block.
4913 let order: Vec<Option<&str>> = rows
4914 .iter()
4915 .map(|row| row.provider_identity.as_deref())
4916 .collect();
4917 assert_eq!(order, vec![Some("TeamA"), None, Some("teama")]);
4918 assert!(pin_for_row(&pins, rows[0]).is_some());
4919 assert!(pin_for_row(&pins, rows[2]).is_none());
4920 }
4921
4922 #[test]
4923 fn locked_model_keeps_keyboard_focus_visible_without_becoming_selectable() {
4924 let mut picker = test_picker();
4925 picker.model_rows[0].selectable = false;
4926 picker.model_rows[0].blocked_reason = Some("missing key".to_string());
4927 let area = Rect::new(0, 0, 100, 32);
4928 let mut buf = Buffer::empty(area);
4929 picker.render(area, &mut buf);
4930 let hit = picker
4931 .row_hitboxes
4932 .borrow()
4933 .iter()
4934 .find(|(_, pane, idx)| *pane == Pane::Model && *idx == 0)
4935 .unwrap()
4936 .0;
4937 assert_eq!(buf[(hit.right() - 1, hit.y)].bg, palette::SELECTION_BG);
4938 assert!(!picker.model_rows[0].selectable);
4939 }
4940
4941 #[test]
4942 fn workbench_hover_preserves_model_selection_and_clears_on_keyboard_input() {
4943 let mut picker = test_picker();
4944 picker
4945 .model_rows
4946 .push(model_row(ProviderKind::Deepseek, true));
4947 let area = Rect::new(0, 0, 100, 32);
4948 let mut buf = Buffer::empty(area);
4949 picker.render(area, &mut buf);
4950 let hit = picker
4951 .row_hitboxes
4952 .borrow()
4953 .iter()
4954 .find(|(_, pane, idx)| *pane == Pane::Model && *idx == 1)
4955 .unwrap()
4956 .0;
4957 picker.handle_mouse(MouseEvent {
4958 kind: MouseEventKind::Moved,
4959 column: hit.x,
4960 row: hit.y,
4961 modifiers: KeyModifiers::NONE,
4962 });
4963 assert_eq!(picker.hovered_row, Some((Pane::Model, 1)));
4964 assert_eq!(picker.selected_model_idx, 0);
4965 picker.render(area, &mut buf);
4966 assert_eq!(buf[(hit.right() - 1, hit.y)].bg, palette::SURFACE_ELEVATED);
4967 picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
4968 assert_eq!(picker.hovered_row, None);
4969 }
4970
4971 fn test_picker() -> ModelPickerView {
4972 ModelPickerView {
4973 initial_model: "model".to_string(),
4974 previous_model: "model".to_string(),
4975 initial_provider: ProviderKind::Openai,
4976 initial_provider_identity: ProviderKind::Openai.as_str().to_string(),
4977 initial_identity: Some(
4978 Config::default()
4979 .builtin_provider_identity(ProviderKind::Openai)
4980 .unwrap(),
4981 ),
4982 initial_effort: ReasoningEffort::Auto,
4983 selected_effort_request: ReasoningEffort::Auto,
4984 active_accepts_custom_model_ids: false,
4985 query: String::new(),
4986 selected_model_idx: 0,
4987 selected_effort_idx: 0,
4988 focus: Pane::Model,
4989 show_custom_model_row: false,
4990 model_rows: vec![model_row(ProviderKind::Openai, true)],
4991 route_config: Config::default(),
4992 provider_health: Default::default(),
4993 view: ModelListView::Configured,
4994 configured_providers: Vec::new(),
4995 row_hitboxes: RefCell::new(Vec::new()),
4996 last_mouse_selected: None,
4997 hovered_row: None,
4998 locale: Locale::En,
4999 pinned_models: Vec::new(),
5000 projection: RefCell::new(None),
5001 sort: None,
5002 column_hitboxes: RefCell::new(Vec::new()),
5003 pane_hitboxes: RefCell::new(Vec::new()),
5004 catalog_action_hitbox: RefCell::new(None),
5005 catalog_action_hovered: false,
5006 purpose: ModelPickerPurpose::Session,
5007 assignment_context: None,
5008 notice: None,
5009 theme: codewhale_palette::UI_THEME,
5010 }
5011 }
5012
5013 /// Opened for a Fleet row, Enter hands the editor the absolute route
5014 /// instead of switching the session; the startup-default chord is the
5015 /// same pick.
5016 #[test]
5017 fn catalog_header_click_matches_keyboard_at_compact_and_wide_sizes() {
5018 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
5019 let mut mouse_picker = test_picker();
5020 let mut key_picker = test_picker();
5021 let area = Rect::new(0, 0, width, height);
5022 mouse_picker.render(area, &mut Buffer::empty(area));
5023 let hit = mouse_picker
5024 .catalog_action_hitbox
5025 .borrow()
5026 .expect("catalog action");
5027 assert!(area.contains((hit.x, hit.y).into()));
5028 assert!(hit.right() <= area.right());
5029 assert!(matches!(
5030 mouse_picker.handle_mouse(MouseEvent {
5031 kind: MouseEventKind::Down(MouseButton::Left),
5032 column: hit.x,
5033 row: hit.y,
5034 modifiers: KeyModifiers::NONE,
5035 }),
5036 ViewAction::None
5037 ));
5038 key_picker.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
5039 assert_eq!(mouse_picker.view, key_picker.view);
5040 assert_eq!(
5041 mouse_picker.selected_model_idx,
5042 key_picker.selected_model_idx
5043 );
5044 let empty = Rect::new(0, 0, 0, 0);
5045 mouse_picker.render(empty, &mut Buffer::empty(empty));
5046 assert!(mouse_picker.catalog_action_hitbox.borrow().is_none());
5047 }
5048 }
5049
5050 #[test]
5051 fn fleet_purpose_enter_hands_the_absolute_route_to_the_editor() {
5052 let mut picker = test_picker();
5053 picker.purpose = ModelPickerPurpose::FleetRoute {
5054 target: FleetRouteTarget::Member(1),
5055 editor_id: uuid::Uuid::nil(),
5056 initial_reasoning: None,
5057 allow_inherit: true,
5058 };
5059 let action = picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
5060 assert!(
5061 matches!(
5062 &action,
5063 ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked {
5064 target: FleetRouteTarget::Member(1),
5065 provider: ProviderKind::Openai,
5066 provider_id: Some(provider_id),
5067 model,
5068 editor_id,
5069 reasoning: None,
5070 }) if provider_id == "openai" && model == "model" && editor_id.is_nil()
5071 ),
5072 "{action:?}"
5073 );
5074 assert!(matches!(
5075 picker.handle_key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)),
5076 ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked { .. })
5077 ));
5078 // The session-purpose picker is untouched by the new purpose.
5079 let mut session = test_picker();
5080 assert!(matches!(
5081 session.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
5082 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { .. })
5083 ));
5084 }
5085
5086 #[test]
5087 fn profile_role_picker_assigns_only_its_owner_and_search_cancels_before_closing() {
5088 let editor_id = uuid::Uuid::new_v4();
5089 let mut picker = test_picker().with_assignment_context("reviewer", "Choose where to save");
5090 picker.purpose = ModelPickerPurpose::FleetProfileRoute {
5091 editor_id,
5092 initial_reasoning: None,
5093 };
5094 assert!(
5095 matches!(picker.build_apply_event(false), ViewEvent::FleetProfileRoutePicked {
5096 editor_id: owner, provider: ProviderKind::Openai, model, ..
5097 } if owner == editor_id && model == "model")
5098 );
5099 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
5100 let area = Rect::new(0, 0, width, height);
5101 let mut buf = Buffer::empty(area);
5102 picker.render(area, &mut buf);
5103 let text: String = buf.content().iter().map(|cell| cell.symbol()).collect();
5104 assert!(text.contains("reviewer"), "{text}");
5105 assert!(text.contains("Choose where to save"), "{text}");
5106 }
5107 picker.update_query("model".into());
5108 assert!(matches!(
5109 picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
5110 ViewAction::None
5111 ));
5112 assert!(picker.query.is_empty());
5113 assert!(
5114 matches!(picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { editor_id: owner }) if owner == editor_id)
5115 );
5116 picker.model_rows[0].selectable = false;
5117 *picker.projection.get_mut() = None;
5118 assert!(matches!(
5119 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
5120 ViewAction::Emit(ViewEvent::StatusMessage { .. })
5121 ));
5122 }
5123
5124 #[test]
5125 fn fleet_locked_builtin_explains_setup_without_switching_the_session() {
5126 let mut picker = test_picker();
5127 picker.purpose = ModelPickerPurpose::FleetRoute {
5128 target: FleetRouteTarget::Operator,
5129 editor_id: uuid::Uuid::nil(),
5130 initial_reasoning: None,
5131 allow_inherit: true,
5132 };
5133 picker.model_rows[0].selectable = false;
5134 *picker.projection.get_mut() = None;
5135 assert!(
5136 matches!(picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ViewAction::Emit(ViewEvent::StatusMessage { message }) if message.contains("/provider"))
5137 );
5138 }
5139
5140 #[test]
5141 fn fleet_picker_keeps_the_rows_exact_pin_and_effort_after_refresh() {
5142 let _env = crate::test_support::lock_test_env();
5143 let _live = crate::provider_lake::lock_live_snapshot();
5144 let (config, mut app, home, _workspace) =
5145 resumed_openrouter_session_with_named_custom_routes();
5146 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5147 crate::provider_catalog_live::reset_cache_for_test();
5148 crate::provider_lake::clear_live_snapshot();
5149 app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
5150 catalog_view: true,
5151 view: Some("catalog".to_string()),
5152 selected_row_id: Some(app.model.clone()),
5153 });
5154 let session_route = (app.api_provider, app.model.clone());
5155 let mut picker = ModelPickerView::new_for_fleet_route(
5156 &app,
5157 &config,
5158 FleetRouteTarget::Member(0),
5159 uuid::Uuid::nil(),
5160 FleetRouteSelection {
5161 provider: Some("command_code".to_string()),
5162 model: Some("deepseek/deepseek-v4-flash".to_string()),
5163 reasoning: Some(ReasoningEffort::High),
5164 allow_inherit: true,
5165 },
5166 );
5167 assert_eq!(
5168 picker.resolved_provider_identity().as_deref(),
5169 Some("command_code")
5170 );
5171 assert_eq!(picker.resolved_model(), "deepseek/deepseek-v4-flash");
5172 assert_eq!(picker.selected_effort_request, ReasoningEffort::High);
5173 assert!(picker.can_edit_effort());
5174 assert_eq!(
5175 picker
5176 .visible_model_rows()
5177 .iter()
5178 .filter(|row| row.id == "deepseek/deepseek-v4-flash"
5179 && row_provider_identity(row) == Some("command_code"))
5180 .count(),
5181 1
5182 );
5183 picker.selected_effort_request = ReasoningEffort::Low;
5184 picker.re_resolve_from_app(&app, &config);
5185 assert!(picker.can_edit_effort());
5186 assert!(matches!(
5187 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
5188 ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked {
5189 provider: ProviderKind::Custom, provider_id: Some(identity), model,
5190 reasoning: Some(ReasoningEffort::Low), ..
5191 }) if identity == "command_code" && model == "deepseek/deepseek-v4-flash"
5192 ));
5193 assert_eq!((app.api_provider, app.model.clone()), session_route);
5194 assert!(matches!(
5195 picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
5196 ViewAction::EmitAndClose(ViewEvent::FleetAssignmentPickerDismissed { .. })
5197 ));
5198 for query in [
5199 "openrouter:new-fixture-model",
5200 "openrouter new-fixture-model",
5201 ] {
5202 picker.update_query(query.to_string());
5203 assert_eq!(picker.resolved_provider(), Some(ProviderKind::Openrouter));
5204 assert_eq!(picker.resolved_model(), "new-fixture-model");
5205 }
5206 // A slash is part of a model id, not a provider switch: compatible
5207 // routes routinely serve names such as openai/gpt-5.
5208 picker.update_query("openrouter/new-fixture-model".to_string());
5209 assert_eq!(picker.resolved_provider(), Some(ProviderKind::Custom));
5210 assert_eq!(picker.resolved_model(), "openrouter/new-fixture-model");
5211 }
5212
5213 #[test]
5214 fn fleet_shortlist_picker_has_no_inherit_or_reasoning_edits() {
5215 let _env = crate::test_support::lock_test_env();
5216 let _live = crate::provider_lake::lock_live_snapshot();
5217 let (config, app, home, _workspace) = resumed_openrouter_session_with_named_custom_routes();
5218 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5219 let mut picker = ModelPickerView::new_for_fleet_route(
5220 &app,
5221 &config,
5222 FleetRouteTarget::Member(0),
5223 uuid::Uuid::nil(),
5224 FleetRouteSelection {
5225 provider: Some("command_code".to_string()),
5226 model: Some("deepseek/deepseek-v4-flash".to_string()),
5227 reasoning: None,
5228 allow_inherit: false,
5229 },
5230 );
5231 for _ in 0..2 {
5232 assert!(!picker.can_edit_effort());
5233 assert!(
5234 picker
5235 .visible_model_rows()
5236 .iter()
5237 .all(|row| row.id != "auto")
5238 );
5239 picker.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
5240 assert_eq!(picker.focus, Pane::Model);
5241 render_text(&picker, 150, 42);
5242 assert!(
5243 picker
5244 .pane_hitboxes
5245 .borrow()
5246 .iter()
5247 .all(|(_, pane)| *pane == Pane::Model)
5248 );
5249 assert!(matches!(
5250 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
5251 ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked {
5252 reasoning: None,
5253 ..
5254 })
5255 ));
5256 picker.re_resolve_from_app(&app, &config);
5257 }
5258 picker.update_query("auto".to_string());
5259 assert!(
5260 !matches!(picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked { model, .. }) if model == "auto")
5261 );
5262 }
5263
5264 #[test]
5265 fn fleet_inherited_route_is_selectable_and_named_without_session_autorouting_claims() {
5266 let _env = crate::test_support::lock_test_env();
5267 let _live = crate::provider_lake::lock_live_snapshot();
5268 let (config, app, home, _workspace) = resumed_openrouter_session_with_named_custom_routes();
5269 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5270 let mut picker = ModelPickerView::new_for_fleet_route(
5271 &app,
5272 &config,
5273 FleetRouteTarget::Member(0),
5274 uuid::Uuid::nil(),
5275 FleetRouteSelection {
5276 provider: None,
5277 model: None,
5278 reasoning: None,
5279 allow_inherit: true,
5280 },
5281 );
5282 assert_eq!(picker.resolved_model(), "auto");
5283 assert!(picker.selected_model_is_selectable());
5284 assert!(
5285 render_text(&picker, 150, 42)
5286 .contains(tr(app.ui_locale, MessageId::FleetRouteInherited).as_ref())
5287 );
5288 assert_eq!(picker.current_efforts(), vec![ReasoningEffort::Auto]);
5289 assert!(
5290 matches!(picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), ViewAction::EmitAndClose(ViewEvent::FleetRoutePicked { model, reasoning: None, .. }) if model == "auto")
5291 );
5292 }
5293
5294 fn render_text(picker: &ModelPickerView, width: u16, height: u16) -> String {
5295 let area = Rect::new(0, 0, width, height);
5296 let mut buffer = Buffer::empty(area);
5297 picker.render(area, &mut buffer);
5298 (0..height)
5299 .map(|y| {
5300 (0..width)
5301 .map(|x| buffer[(x, y)].symbol())
5302 .collect::<String>()
5303 })
5304 .collect::<Vec<_>>()
5305 .join("\n")
5306 }
5307
5308 #[test]
5309 fn deepseek_picker_heading_hides_legacy_family_metadata() {
5310 assert_eq!(
5311 catalog_family_for_identity(ProviderKind::Deepseek, None, "deepseek-v4-pro").as_deref(),
5312 Some("DeepSeek")
5313 );
5314 }
5315
5316 #[test]
5317 fn default_view_shows_only_sectioned_rows_and_auto() {
5318 let row = model_row(ProviderKind::Deepseek, false);
5319 assert!(!model_row_visible_by_default(&row));
5320 assert!(model_row_visible_by_default(&model_row(
5321 ProviderKind::Deepseek,
5322 true
5323 )));
5324 let mut auto = model_row(ProviderKind::Deepseek, false);
5325 auto.provider = None;
5326 assert!(model_row_visible_by_default(&auto));
5327 }
5328
5329 #[test]
5330 fn lowercase_picker_action_letters_begin_a_model_search() {
5331 for ch in ['a', 'p', 'r'] {
5332 let mut picker = test_picker();
5333 assert!(matches!(
5334 picker.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
5335 ViewAction::None
5336 ));
5337 assert_eq!(picker.query, ch.to_string(), "{ch} must begin a search");
5338 assert_eq!(picker.view, ModelListView::Configured);
5339 }
5340 }
5341
5342 #[test]
5343 fn shifted_picker_actions_cycle_views_pin_and_refresh_explicitly() {
5344 let mut picker = test_picker();
5345
5346 assert!(matches!(
5347 picker.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)),
5348 ViewAction::None
5349 ));
5350 assert_eq!(picker.view, ModelListView::Catalog);
5351 assert!(picker.query.is_empty());
5352
5353 assert!(matches!(
5354 picker.handle_key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT)),
5355 ViewAction::Emit(ViewEvent::ModelPickerTogglePin {
5356 provider: ProviderKind::Openai,
5357 provider_id: None,
5358 model,
5359 }) if model == "model"
5360 ));
5361 assert!(picker.query.is_empty());
5362
5363 assert!(matches!(
5364 picker.handle_key(KeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT)),
5365 ViewAction::Emit(ViewEvent::ModelPickerToggleFleet {
5366 provider: ProviderKind::Openai,
5367 provider_id: None,
5368 model,
5369 }) if model == "model"
5370 ));
5371 assert!(picker.query.is_empty());
5372
5373 assert!(matches!(
5374 picker.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)),
5375 ViewAction::Emit(ViewEvent::ModelPickerRefresh)
5376 ));
5377 }
5378
5379 #[test]
5380 fn fleet_models_lead_the_pins_the_picker_sorts_by() {
5381 let _lock = crate::test_support::lock_test_env();
5382 let temp = tempfile::tempdir().expect("tempdir");
5383 let home = temp.path().join("home");
5384 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.as_os_str());
5385 let workspace = temp.path().join("repo");
5386 std::fs::create_dir_all(&workspace).expect("workspace");
5387 crate::fleet::members::add_fleet_model(
5388 &workspace,
5389 "openrouter",
5390 "z-ai/glm-5.3-flash",
5391 &["scout".to_string()],
5392 )
5393 .expect("fleet add");
5394
5395 let options = crate::tui::app::TuiOptions {
5396 ..crate::test_support::test_tui_options(workspace.clone())
5397 };
5398 let mut app = crate::tui::app::App::new(options, &Config::default());
5399 app.workspace = workspace;
5400 app.pinned_models = vec![PinnedModel {
5401 provider: "anthropic".to_string(),
5402 model: "claude-haiku-4-5".to_string(),
5403 label: None,
5404 }];
5405
5406 let pins = picker_pins_for_app(&app);
5407 assert_eq!(pins.len(), 2, "fleet model then the person's pin: {pins:?}");
5408 assert_eq!(pins[0].provider, "openrouter");
5409 assert_eq!(pins[0].model, "z-ai/glm-5.3-flash");
5410 assert_eq!(pins[0].label.as_deref(), Some("fleet · explore"));
5411 assert_eq!(pins[1].model, "claude-haiku-4-5");
5412 }
5413
5414 #[test]
5415 fn fleet_case_distinct_pins_keep_labels_order_and_refresh_selection() {
5416 std::thread::Builder::new()
5417 .stack_size(16 * 1024 * 1024)
5418 .spawn(|| {
5419 let _lock = crate::test_support::lock_test_env();
5420 let root = tempfile::tempdir().unwrap();
5421 let _home = crate::test_support::EnvVarGuard::set(
5422 "CODEWHALE_HOME",
5423 root.path().join("home"),
5424 );
5425 let workspace = root.path().join("workspace");
5426 std::fs::create_dir_all(&workspace).unwrap();
5427 let lower = "preview-fixture";
5428 let upper = "Preview-fixture";
5429 let mut config: Config = toml::from_str(include_str!(
5430 "../../../config/tests/fixtures/custom_models.toml"
5431 ))
5432 .unwrap();
5433 config.default_text_model = Some(lower.into());
5434 config
5435 .set_provider_model_override(
5436 &config.test_identity_for_kind(ProviderKind::Deepseek),
5437 Some(lower.into()),
5438 )
5439 .unwrap();
5440 config
5441 .set_provider_api_key_override(
5442 &config.test_identity_for_kind(ProviderKind::Deepseek),
5443 Some("fixture-key".into()),
5444 )
5445 .unwrap();
5446 config.custom_models.as_mut().unwrap()[0].id = lower.into();
5447 let mut second = config.custom_models.as_ref().unwrap()[0].clone();
5448 second.id = upper.into();
5449 config.custom_models.as_mut().unwrap().push(second);
5450 crate::fleet::members::add_fleet_model(
5451 &workspace,
5452 "deepseek",
5453 lower,
5454 &["scout".into()],
5455 )
5456 .unwrap();
5457 crate::fleet::members::add_fleet_model(
5458 &workspace,
5459 "deepseek",
5460 upper,
5461 &["reviewer".into()],
5462 )
5463 .unwrap();
5464 let options = crate::test_support::test_tui_options(workspace.clone());
5465 let mut app = App::new(options, &config);
5466 app.workspace = workspace.clone();
5467 let pins = picker_pins_for_app(&app);
5468 let rows = picker_model_rows_for_app(&app, &config);
5469 for pin in &pins {
5470 let row = rows
5471 .iter()
5472 .find(|row| {
5473 row.provider == Some(ProviderKind::Deepseek) && row.id == pin.model
5474 })
5475 .unwrap();
5476 assert!(row.hint.starts_with(pin.label.as_deref().unwrap()));
5477 assert!(
5478 row.hint
5479 .contains(&format!("exact deepseek / {}", pin.model))
5480 );
5481 }
5482 let mut picker = ModelPickerView::new(&app, &config);
5483 let visible = picker.visible_model_rows();
5484 // The current route leads (#6533); the pins follow it. The
5485 // configured `default_text_model` is the startup route, so the
5486 // lower pin is also the current row.
5487 assert_eq!(app.model, lower);
5488 assert_eq!(visible[0].id, app.model);
5489 let lower_index = visible.iter().position(|row| row.id == lower).unwrap();
5490 let upper_index = visible.iter().position(|row| row.id == upper).unwrap();
5491 assert!(
5492 lower_index < upper_index,
5493 "saved pin order precedes lexical order"
5494 );
5495 drop(visible);
5496 picker.selected_model_idx = upper_index;
5497 picker.re_resolve_from_app(&app, &config);
5498 assert_eq!(
5499 picker.resolved_model(),
5500 upper,
5501 "refresh cannot select its case sibling"
5502 );
5503
5504 // The still-saved upper route remains a distinct stale row after its
5505 // declaration disappears; a live lower row cannot hide it.
5506 config
5507 .custom_models
5508 .as_mut()
5509 .unwrap()
5510 .retain(|row| row.id == lower);
5511 let options = crate::test_support::test_tui_options(workspace.clone());
5512 let mut reloaded = App::new(options, &config);
5513 reloaded.workspace = workspace;
5514 let rows = picker_model_rows_for_app(&reloaded, &config);
5515 let stale = rows
5516 .iter()
5517 .filter(|row| row.provider == Some(ProviderKind::Deepseek) && row.id == upper)
5518 .collect::<Vec<_>>();
5519 assert_eq!(stale.len(), 1);
5520 assert_eq!(stale[0].blocked_reason.as_deref(), Some("stale pin"));
5521 assert!(
5522 rows.iter()
5523 .any(|row| row.provider == Some(ProviderKind::Deepseek)
5524 && row.id == lower
5525 && row.blocked_reason.as_deref() != Some("stale pin"))
5526 );
5527 })
5528 .unwrap()
5529 .join()
5530 .unwrap();
5531 }
5532
5533 #[test]
5534 fn wide_picker_footer_advertises_shifted_view_and_pin_actions() {
5535 let picker = test_picker();
5536 let text = render_text(&picker, 100, 30);
5537
5538 assert!(text.contains("⇧A"), "missing shifted view hint: {text}");
5539 assert!(text.contains("⇧P"), "missing shifted pin hint: {text}");
5540 assert!(text.contains("⇧F"), "missing shifted fleet hint: {text}");
5541 }
5542
5543 #[test]
5544 fn full_catalog_navigation_sort_refresh_and_mouse_stay_coherent() {
5545 const PROBE: &str = "CODEWHALE_PICKER_CATALOG_PROBE";
5546 if std::env::var_os(PROBE).is_none() {
5547 let fixture = tempfile::tempdir().expect("picker fixture");
5548 let home = fixture.path().join("home");
5549 let workspace = fixture.path().join("workspace");
5550 std::fs::create_dir_all(&home).unwrap();
5551 std::fs::create_dir_all(&workspace).unwrap();
5552 let output = std::process::Command::new(std::env::current_exe().unwrap())
5553 .args(["--exact", "tui::model_picker::tests::full_catalog_navigation_sort_refresh_and_mouse_stay_coherent", "--nocapture", "--test-threads=1"])
5554 .env_clear()
5555 .env(PROBE, "1")
5556 .env("HOME", &home)
5557 .env("USERPROFILE", &home)
5558 .env("XDG_CONFIG_HOME", home.join("config"))
5559 .env("XDG_CACHE_HOME", home.join("cache"))
5560 .env("XDG_DATA_HOME", home.join("data"))
5561 .env("CODEWHALE_HOME", home.join(".codewhale"))
5562 .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
5563 .env("CODEWHALE_NO_UPDATE_CHECK", "1")
5564 .env("CODEWHALE_TELEMETRY", "0")
5565 .current_dir(&workspace)
5566 .output().expect("isolated picker test");
5567 assert!(
5568 output.status.success(),
5569 "{}\n{}",
5570 String::from_utf8_lossy(&output.stdout),
5571 String::from_utf8_lossy(&output.stderr)
5572 );
5573 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
5574 return;
5575 }
5576 let config = Config::default();
5577 let mut app = App::new(
5578 crate::test_support::test_tui_options(std::env::current_dir().unwrap()),
5579 &config,
5580 );
5581 let mut picker = ModelPickerView::new(&app, &config);
5582 picker.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT));
5583 let count = picker.model_row_count();
5584 assert!(count >= 200, "use the actual full catalog: {count} rows");
5585 let projection_storage = || {
5586 let projection = picker.projection.borrow();
5587 let projection = projection.as_ref().unwrap();
5588 (projection.indices.as_ptr(), projection.rows.as_ptr())
5589 };
5590 let initial_storage = projection_storage();
5591 let started = std::time::Instant::now();
5592 for _ in 0..30 {
5593 picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
5594 render_text(&picker, 150, 42);
5595 let projection = picker.projection.borrow();
5596 let projection = projection.as_ref().unwrap();
5597 assert_eq!(
5598 (projection.indices.as_ptr(), projection.rows.as_ptr()),
5599 initial_storage,
5600 "navigation must not rebuild the full catalog or formatted rows"
5601 );
5602 }
5603 assert!(
5604 started.elapsed() < std::time::Duration::from_secs(5),
5605 "30 navigation+render iterations over {count} catalog rows must stay fast"
5606 );
5607
5608 // Every catalog entry must remain visible when highlighted, even when
5609 // adjacent providers each require a separate family heading.
5610 for (width, height) in [(80, 24), (100, 30), (150, 42)] {
5611 for selected in 0..count {
5612 picker.selected_model_idx = selected;
5613 render_text(&picker, width, height);
5614 let panes = picker.pane_hitboxes.borrow();
5615 let area = panes
5616 .iter()
5617 .find(|(_, pane)| *pane == Pane::Model)
5618 .unwrap()
5619 .0;
5620 let hitboxes = picker.row_hitboxes.borrow();
5621 assert!(
5622 hitboxes
5623 .iter()
5624 .any(|(_, pane, index)| *pane == Pane::Model && *index == selected),
5625 "selected row {selected} disappeared at {width}x{height}"
5626 );
5627 for (rect, pane, _) in hitboxes.iter().filter(|(_, pane, _)| *pane == Pane::Model) {
5628 assert_eq!(*pane, Pane::Model);
5629 assert!(rect.y >= area.y && rect.bottom() <= area.bottom());
5630 }
5631 }
5632 }
5633
5634 picker.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
5635 render_text(&picker, 150, 42);
5636 let (effort_rect, _, _) = *picker
5637 .row_hitboxes
5638 .borrow()
5639 .iter()
5640 .find(|(_, pane, index)| *pane == Pane::Effort && *index == 1)
5641 .unwrap();
5642 picker.handle_mouse(MouseEvent {
5643 kind: MouseEventKind::Down(MouseButton::Left),
5644 column: effort_rect.x + 1,
5645 row: effort_rect.y,
5646 modifiers: KeyModifiers::NONE,
5647 });
5648 assert_eq!(picker.focus, Pane::Effort);
5649 let before = picker.selected_model_idx;
5650 let requested_effort = picker.selected_effort_request;
5651 let model_area = picker
5652 .pane_hitboxes
5653 .borrow()
5654 .iter()
5655 .find(|(_, pane)| *pane == Pane::Model)
5656 .unwrap()
5657 .0;
5658 picker.handle_mouse(MouseEvent {
5659 kind: MouseEventKind::ScrollDown,
5660 column: model_area.x + 1,
5661 row: model_area.y + 3,
5662 modifiers: KeyModifiers::NONE,
5663 });
5664 assert_eq!(picker.focus, Pane::Model);
5665 assert_eq!(picker.selected_model_idx, wrapping_next(before, count));
5666 assert_eq!(picker.selected_effort_request, requested_effort);
5667
5668 // Actual header hitboxes sort in both directions without changing the
5669 // selected provider/model; missing context is never treated as zero.
5670 let selected = (picker.resolved_provider(), picker.resolved_model());
5671 for column in [
5672 ModelSortColumn::Model,
5673 ModelSortColumn::Provider,
5674 ModelSortColumn::Context,
5675 ] {
5676 for descending in [false, true] {
5677 render_text(&picker, 150, 42);
5678 let rect = picker
5679 .column_hitboxes
5680 .borrow()
5681 .iter()
5682 .find(|(_, target)| *target == column)
5683 .unwrap()
5684 .0;
5685 picker.handle_mouse(MouseEvent {
5686 kind: MouseEventKind::Down(MouseButton::Left),
5687 column: rect.x,
5688 row: rect.y,
5689 modifiers: KeyModifiers::NONE,
5690 });
5691 assert_eq!(picker.sort, Some(ModelSort { column, descending }));
5692 assert_eq!(
5693 picker.focus,
5694 Pane::Model,
5695 "header click must focus the Model pane"
5696 );
5697 assert_eq!(
5698 (picker.resolved_provider(), picker.resolved_model()),
5699 selected
5700 );
5701 let visible = picker.visible_model_rows();
5702 let rows: Vec<_> = visible
5703 .iter()
5704 .filter(|row| row.provider.is_some())
5705 .collect();
5706 if column == ModelSortColumn::Context {
5707 let mut unknown = false;
5708 let mut previous = None;
5709 for row in rows {
5710 match row.metadata.context_window {
5711 None => unknown = true,
5712 Some(context) => {
5713 assert!(!unknown, "unknown context must stay last");
5714 if let Some(previous) = previous {
5715 assert!(if descending {
5716 previous >= context
5717 } else {
5718 previous <= context
5719 });
5720 }
5721 previous = Some(context);
5722 }
5723 }
5724 }
5725 }
5726 }
5727 }
5728 picker.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
5729 assert_eq!(picker.sort, None, "cycle returns to default view/pin order");
5730 picker.update_query("openrouter".to_string());
5731 assert!(
5732 picker
5733 .visible_model_rows()
5734 .iter()
5735 .all(|row| row.provider == Some(ProviderKind::Openrouter))
5736 );
5737 assert_eq!(
5738 picker.projection.borrow().as_ref().unwrap().query,
5739 "openrouter"
5740 );
5741 picker.update_query(String::new());
5742 assert_eq!(picker.model_row_count(), count);
5743 let selected = (picker.resolved_provider(), picker.resolved_model());
5744 app.pinned_models.push(PinnedModel {
5745 provider: "openrouter".into(),
5746 model: "z-ai/glm-5.3-flash".into(),
5747 label: None,
5748 });
5749 picker.re_resolve_from_app(&app, &config);
5750 assert_eq!(picker.visible_model_rows()[0].id, "z-ai/glm-5.3-flash");
5751 assert_eq!(
5752 (picker.resolved_provider(), picker.resolved_model()),
5753 selected
5754 );
5755
5756 // A real catalog/readiness refresh replaces cached presentation facts.
5757 let mut offering =
5758 catalog_offering_for_model(ProviderKind::Deepseek, "deepseek-v4-pro").unwrap();
5759 offering.limit.as_mut().unwrap().context = Some(777_000);
5760 crate::provider_lake::set_live_snapshot(
5761 codewhale_config::catalog::CatalogSnapshot {
5762 offerings: vec![offering],
5763 },
5764 crate::provider_lake::LiveSource::ModelsDev,
5765 );
5766 picker.re_resolve_from_app(&app, &config);
5767 let visible = picker.visible_model_rows();
5768 let row = visible
5769 .iter()
5770 .find(|row| row.provider == Some(ProviderKind::Deepseek) && row.id == "deepseek-v4-pro")
5771 .unwrap();
5772 assert_eq!(row.metadata.context_window, Some(777_000));
5773 let index = visible
5774 .iter()
5775 .position(|row| {
5776 row.provider == Some(ProviderKind::Deepseek) && row.id == "deepseek-v4-pro"
5777 })
5778 .unwrap();
5779 assert!(
5780 picker.projection.borrow().as_ref().unwrap().rows[index]
5781 .meta
5782 .contains(&format_context_window(777_000))
5783 );
5784 }
5785
5786 #[test]
5787 fn family_heading_viewport_reserves_the_selected_row_before_hitboxes() {
5788 let rows: Vec<_> = (0..32)
5789 .map(|index| PaneRow {
5790 primary: format!("model-{index}"),
5791 route: format!("provider-{index}"),
5792 family: Some(format!("family-{index}")),
5793 ..PaneRow::default()
5794 })
5795 .collect();
5796 for height in 1..12 {
5797 for selected in 0..rows.len() {
5798 let (start, end) = pane_row_window(selected, &rows, height);
5799 assert!(
5800 start <= selected && selected < end,
5801 "{start}..{end} omits {selected} at height {height}"
5802 );
5803 let lines: usize = (start..end)
5804 .map(|index| 1 + usize::from(height > 1 && family_header_before(&rows, index)))
5805 .sum();
5806 assert!(lines <= height);
5807 }
5808 }
5809 }
5810
5811 #[test]
5812 fn baseten_picker_models_use_exact_identity_and_direct_provider_label() {
5813 let _env = crate::test_support::lock_test_env();
5814 let _live = crate::provider_lake::lock_live_snapshot();
5815 let home = tempfile::tempdir().expect("test home");
5816 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5817 crate::provider_catalog_live::reset_cache_for_test();
5818 crate::provider_lake::clear_live_snapshot();
5819
5820 // No compiled seeds: a custom route offers nothing until a live
5821 // listing lands for its exact endpoint (#6289).
5822 assert!(
5823 provider_catalog_model_ids(
5824 ProviderKind::Custom,
5825 codewhale_config::catalog::BASETEN_PROVIDER_ID,
5826 codewhale_config::catalog::BASETEN_BASE_URL,
5827 )
5828 .is_empty()
5829 );
5830
5831 let fingerprint = codewhale_config::catalog::base_url_fingerprint(
5832 codewhale_config::catalog::BASETEN_BASE_URL,
5833 );
5834 crate::provider_catalog_live::record_success(
5835 codewhale_config::catalog::ProviderCatalogDelta {
5836 provider: codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string(),
5837 base_url_fingerprint: fingerprint.clone(),
5838 fetched_at: 1,
5839 offerings: vec![codewhale_config::catalog::CatalogOffering {
5840 provider: codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string(),
5841 wire_model_id: codewhale_config::catalog::BASETEN_DEFAULT_MODEL.to_string(),
5842 endpoint_key: "chat".to_string(),
5843 source: CatalogSource::Live {
5844 base_url_fingerprint: fingerprint,
5845 fetched_at: 1,
5846 },
5847 ..Default::default()
5848 }],
5849 },
5850 );
5851
5852 let models = provider_catalog_model_ids(
5853 ProviderKind::Custom,
5854 codewhale_config::catalog::BASETEN_PROVIDER_ID,
5855 codewhale_config::catalog::BASETEN_BASE_URL,
5856 );
5857 assert_eq!(
5858 models,
5859 vec![codewhale_config::catalog::BASETEN_DEFAULT_MODEL.to_string()]
5860 );
5861
5862 let row = ModelPickerRow {
5863 id: codewhale_config::catalog::BASETEN_DEFAULT_MODEL.to_string(),
5864 provider: Some(ProviderKind::Custom),
5865 provider_identity: Some(codewhale_config::catalog::BASETEN_PROVIDER_ID.to_string()),
5866 admitted_identity: None,
5867 hint: String::new(),
5868 metadata: EffectivePickerMetadata::default(),
5869 selectable: true,
5870 blocked_reason: None,
5871 default_rank: None,
5872 credentialed: false,
5873 not_listed: None,
5874 };
5875 let labels = route_labels_for_rows(&[&row]);
5876 // No compiled display names: the route label is the table key itself.
5877 assert_eq!(labels.get("baseten").map(String::as_str), Some("baseten"));
5878 }
5879
5880 #[test]
5881 fn provider_catalog_hint_never_calls_failed_or_mismatched_rows_live() {
5882 assert_eq!(
5883 provider_catalog_source_label(Some(&(CatalogStatus::Fresh, true))),
5884 "live"
5885 );
5886 assert_eq!(
5887 provider_catalog_source_label(Some(&(
5888 CatalogStatus::Failed {
5889 reason: CatalogRefreshError::Unauthorized,
5890 },
5891 true,
5892 ))),
5893 "refresh failed (unauthorized)"
5894 );
5895 assert_eq!(
5896 provider_catalog_source_label(Some(&(CatalogStatus::Fresh, false))),
5897 "catalog from different endpoint"
5898 );
5899 assert_eq!(
5900 provider_catalog_source_label(None),
5901 "catalog freshness unknown"
5902 );
5903 }
5904
5905 #[test]
5906 fn first_provider_catalog_failure_is_visible_on_bundled_fallback_rows() {
5907 let _env = crate::test_support::lock_test_env();
5908 let _live = crate::provider_lake::lock_live_snapshot();
5909 let home = tempfile::tempdir().expect("test home");
5910 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5911 crate::provider_catalog_live::reset_cache_for_test();
5912 crate::provider_lake::clear_live_snapshot();
5913
5914 let config = Config {
5915 provider: Some("openrouter".to_string()),
5916 ..Config::default()
5917 };
5918 let base_url = config.base_url_for_route(
5919 &config
5920 .resolve_provider_selection_identity("openrouter")
5921 .unwrap(),
5922 );
5923 let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
5924 crate::provider_catalog_live::record_failure(
5925 "openrouter",
5926 &fingerprint,
5927 CatalogRefreshError::Unauthorized,
5928 );
5929
5930 let model = provider_catalog_model_ids(
5931 ProviderKind::Openrouter,
5932 ProviderKind::Openrouter.as_str(),
5933 crate::config::DEFAULT_OPENROUTER_BASE_URL,
5934 )
5935 .into_iter()
5936 .next()
5937 .expect("bundled OpenRouter fallback");
5938 let mut rows = Vec::new();
5939 let codex_roster = CodexModelRoster {
5940 models: Vec::new(),
5941 freshness: CodexModelCacheFreshness::Missing,
5942 fetched_at: None,
5943 observed_at: None,
5944 observation_persisted: false,
5945 source: "codex_cli_cache",
5946 };
5947 push_provider_model_rows(
5948 &mut rows,
5949 &config.test_identity_for_kind(ProviderKind::Openrouter),
5950 vec![model],
5951 ProviderKind::Openrouter,
5952 &config,
5953 &codex_roster,
5954 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
5955 );
5956 assert_eq!(rows.len(), 1);
5957 assert!(
5958 rows[0].hint.contains("refresh failed (unauthorized)"),
5959 "{}",
5960 rows[0].hint
5961 );
5962
5963 crate::provider_catalog_live::reset_cache_for_test();
5964 crate::provider_lake::clear_live_snapshot();
5965 }
5966
5967 #[test]
5968 fn same_model_on_distinct_custom_routes_keeps_readiness_and_applied_identity() {
5969 let _env = crate::test_support::lock_test_env();
5970 let _live = crate::provider_lake::lock_live_snapshot();
5971 let (config, app, home, _workspace) = resumed_openrouter_session_with_named_custom_routes();
5972 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
5973 crate::provider_catalog_live::reset_cache_for_test();
5974 crate::provider_lake::clear_live_snapshot();
5975
5976 const MODEL: &str = "deepseek/deepseek-v4-flash";
5977 let mut picker = ModelPickerView::new(&app, &config);
5978 // The uncredentialed route is outside the default view (#6533); the
5979 // full catalog lists both.
5980 picker.view = ModelListView::Catalog;
5981 *picker.projection.get_mut() = None;
5982 let shared: Vec<(usize, Option<String>, bool)> = picker
5983 .visible_model_rows()
5984 .iter()
5985 .enumerate()
5986 .filter(|(_, row)| row.provider == Some(ProviderKind::Custom) && row.id == MODEL)
5987 .map(|(index, row)| (index, row.provider_identity.clone(), row.selectable))
5988 .collect();
5989 assert_eq!(
5990 shared.len(),
5991 2,
5992 "one shared model id on two routes must not collapse: {shared:?}"
5993 );
5994
5995 for (index, identity, selectable) in shared {
5996 let identity = identity.expect("custom rows carry their exact route identity");
5997 // Each route is judged by its own table: only the credentialed one
5998 // can be attempted, even though neither is the selected provider.
5999 assert_eq!(
6000 selectable,
6001 identity == "command_code",
6002 "{identity} readiness must come from its own route"
6003 );
6004 picker.selected_model_idx = index;
6005 match picker.build_apply_event(false) {
6006 ViewEvent::ModelPickerApplied {
6007 model,
6008 identity: admitted,
6009 ..
6010 } => {
6011 assert_eq!(model, MODEL);
6012 let admitted = admitted.expect("selected row carries its exact admitted route");
6013 assert_eq!(admitted.provider, ProviderKind::Custom);
6014 assert_eq!(
6015 admitted.persisted_id(),
6016 Some(identity.as_str()),
6017 "applying a row must switch to the route that row describes"
6018 );
6019 }
6020 other => panic!("unexpected picker event: {other:?}"),
6021 }
6022 }
6023
6024 crate::provider_catalog_live::reset_cache_for_test();
6025 crate::provider_lake::clear_live_snapshot();
6026 }
6027
6028 #[test]
6029 fn model_rows_keep_case_distinct_custom_identities() {
6030 let mut rows = Vec::new();
6031 for identity in ["CustomA", "customa"] {
6032 push_model_row(
6033 &mut rows,
6034 "shared-model".to_string(),
6035 Some(ProviderKind::Custom),
6036 Some(identity.to_string()),
6037 String::new(),
6038 EffectivePickerMetadata::default(),
6039 true,
6040 None,
6041 );
6042 }
6043 assert_eq!(rows.len(), 2);
6044 }
6045
6046 /// #6016 fixture: a session resumed on OpenRouter while two named custom
6047 /// routes are configured, neither of them the config's selected provider.
6048 fn resumed_openrouter_session_with_named_custom_routes()
6049 -> (Config, App, tempfile::TempDir, tempfile::TempDir) {
6050 let config: Config = toml::from_str(
6051 r#"
6052 provider = "openrouter"
6053
6054 [providers.openrouter]
6055 api_key = "fixture-openrouter-key"
6056
6057 [providers.command_code]
6058 kind = "openai-compatible"
6059 base_url = "https://command.example.test/v1"
6060 model = "deepseek/deepseek-v4-flash"
6061 api_key = "fixture-command-key"
6062
6063 # Same model id on a second route, deliberately without credentials: the two
6064 # routes must stay separate rows with separate readiness.
6065 [providers.other_code]
6066 kind = "openai-compatible"
6067 base_url = "https://other.example.test/v1"
6068 model = "deepseek/deepseek-v4-flash"
6069 "#,
6070 )
6071 .expect("named custom fixture");
6072
6073 let home = tempfile::tempdir().expect("test home");
6074 let workspace = tempfile::tempdir().expect("test workspace");
6075 let options = crate::test_support::test_tui_options(workspace.path());
6076 let mut app = App::new(options, &config);
6077 // The resumed session stays on the provider it was created with.
6078 app.api_provider = ProviderKind::Openrouter;
6079 app.provider_identity = Some(
6080 config
6081 .builtin_provider_identity(ProviderKind::Openrouter)
6082 .unwrap(),
6083 );
6084 app.model = "z-ai/glm-5.3".to_string();
6085 app.active_route_base_url = crate::config::DEFAULT_OPENROUTER_BASE_URL.to_string();
6086 (config, app, home, workspace)
6087 }
6088
6089 #[test]
6090 fn resumed_session_lists_every_configured_custom_route_by_identity() {
6091 let _env = crate::test_support::lock_test_env();
6092 let _live = crate::provider_lake::lock_live_snapshot();
6093 let (config, app, home, _workspace) = resumed_openrouter_session_with_named_custom_routes();
6094 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
6095 crate::provider_catalog_live::reset_cache_for_test();
6096 crate::provider_lake::clear_live_snapshot();
6097
6098 let rows = picker_model_rows_for_app(&app, &config);
6099 let custom: Vec<_> = rows
6100 .iter()
6101 .filter(|row| row.provider == Some(ProviderKind::Custom))
6102 .collect();
6103 let identities: Vec<_> = custom
6104 .iter()
6105 .filter_map(|row| row.provider_identity.as_deref())
6106 .collect();
6107 assert!(
6108 identities.contains(&"command_code"),
6109 "configured custom route B must stay visible in a resumed session: {custom:#?}"
6110 );
6111 assert!(
6112 identities.contains(&"other_code"),
6113 "every configured custom route keeps its own rows: {custom:#?}"
6114 );
6115 // A credentialed custom route offers its configured model by
6116 // default; one without credentials stays behind search (#6533).
6117 for (identity, visible) in [("command_code", true), ("other_code", false)] {
6118 let row = custom
6119 .iter()
6120 .find(|row| {
6121 row.provider_identity.as_deref() == Some(identity)
6122 && row.id == "deepseek/deepseek-v4-flash"
6123 })
6124 .unwrap_or_else(|| panic!("{identity} row missing: {custom:#?}"));
6125 assert_eq!(
6126 model_row_visible_by_default(row),
6127 visible,
6128 "{identity} default-view visibility: {row:#?}"
6129 );
6130 }
6131
6132 crate::provider_catalog_live::reset_cache_for_test();
6133 crate::provider_lake::clear_live_snapshot();
6134 }
6135
6136 #[test]
6137 fn shared_custom_model_opens_on_exact_active_route_despite_other_route_pin() {
6138 let _env = crate::test_support::lock_test_env();
6139 let _live = crate::provider_lake::lock_live_snapshot();
6140 let (mut config, mut app, home, _workspace) =
6141 resumed_openrouter_session_with_named_custom_routes();
6142 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
6143 crate::provider_catalog_live::reset_cache_for_test();
6144 crate::provider_lake::clear_live_snapshot();
6145
6146 const MODEL: &str = "deepseek/deepseek-v4-flash";
6147 config.provider = Some("other_code".to_string());
6148 config
6149 .providers
6150 .as_mut()
6151 .unwrap()
6152 .custom
6153 .get_mut("other_code")
6154 .unwrap()
6155 .api_key = Some("fixture-other-key".to_string());
6156 app.set_provider_identity(ProviderKind::Custom, "other_code");
6157 app.set_model_selection(MODEL.to_string());
6158 app.active_route_base_url = "https://other.example.test/v1".to_string();
6159 app.pinned_models = vec![PinnedModel {
6160 provider: "command_code".to_string(),
6161 model: MODEL.to_string(),
6162 label: None,
6163 }];
6164
6165 // Opening a picker must keep the current route even if a different
6166 // credentialed route exposing the same wire model sorts first.
6167 let mut picker = ModelPickerView::new(&app, &config);
6168 assert_eq!(
6169 picker.resolved_provider_identity().as_deref(),
6170 Some("other_code")
6171 );
6172 picker.ensure_projection();
6173 {
6174 let projection = picker.projection.borrow();
6175 let rows = &projection.as_ref().unwrap().rows;
6176 assert!(rows.iter().any(|row| row.route == "command_code"));
6177 assert!(rows.iter().any(|row| row.route == "other_code"));
6178 let active: Vec<_> = rows.iter().filter(|row| row.active).collect();
6179 assert_eq!(active.len(), 1);
6180 assert_eq!(active[0].route, "other_code");
6181 }
6182 let rendered = render_text(&picker, 140, 40);
6183 assert!(rendered.contains("command_code"), "{rendered}");
6184 assert!(rendered.contains("other_code"), "{rendered}");
6185
6186 // Legacy memory has no route identity; ambiguity must preserve the
6187 // active route instead of resurrecting the first same-named model.
6188 picker.restore_memory(Some(&crate::tui::app::ModelPickerMemory {
6189 catalog_view: true,
6190 view: Some("catalog".to_string()),
6191 selected_row_id: Some(MODEL.to_string()),
6192 }));
6193 assert!(matches!(
6194 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
6195 ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
6196 identity: Some(identity),
6197 model,
6198 save_as_startup_default: false,
6199 ..
6200 }) if identity.persisted_id() == Some("other_code") && model == MODEL
6201 ));
6202 assert!(matches!(
6203 picker.build_event_with_startup_default(true),
6204 ViewEvent::ModelPickerApplied {
6205 identity: Some(identity),
6206 save_as_startup_default: true,
6207 ..
6208 } if identity.persisted_id() == Some("other_code")
6209 ));
6210 crate::provider_catalog_live::reset_cache_for_test();
6211 crate::provider_lake::clear_live_snapshot();
6212 }
6213
6214 #[test]
6215 fn locked_custom_model_names_exact_auth_route_without_opening_another_key_editor() {
6216 let mut picker = test_picker();
6217 let mut row = model_row(ProviderKind::Custom, true);
6218 row.provider_identity = Some("other_code".to_string());
6219 row.selectable = false;
6220 picker.model_rows = vec![row];
6221 assert!(matches!(
6222 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
6223 ViewAction::Emit(ViewEvent::StatusMessage { message })
6224 if message.contains("other_code/model")
6225 && message.contains("Open /provider and select other_code")
6226 ));
6227
6228 // Built-in providers retain their existing guided-auth handoff.
6229 let mut picker = test_picker();
6230 picker.model_rows[0].selectable = false;
6231 assert!(matches!(
6232 picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
6233 ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth {
6234 identity,
6235 ..
6236 }) if identity.provider == ProviderKind::Openai
6237 ));
6238 }
6239
6240 /// #6533 fixture shaped like the founder's state: a legacy
6241 /// `[enabled_models]` GLM-5.2, a leftover OpenRouter test model, recent
6242 /// use of models no config names, and a Fleet pin on a provider with no
6243 /// configured model.
6244 fn founder_shaped_app(home: &std::path::Path, workspace: &std::path::Path) -> (Config, App) {
6245 let settings_path = crate::settings::Settings::path().expect("settings path");
6246 std::fs::create_dir_all(settings_path.parent().unwrap()).unwrap();
6247 std::fs::write(
6248 &settings_path,
6249 "[enabled_models]\nzai = [\"GLM-5.2\", \"GLM-5.3\"]\n",
6250 )
6251 .unwrap();
6252 assert!(settings_path.starts_with(home));
6253 crate::fleet::members::add_fleet_model(
6254 workspace,
6255 "xai",
6256 "grok-4.6",
6257 &["reviewer".to_string()],
6258 )
6259 .expect("fleet add");
6260 let config: Config = toml::from_str(
6261 r#"
6262 provider = "deepseek"
6263
6264 [providers.deepseek]
6265 api_key = "fixture-deepseek-key"
6266 model = "deepseek-flash"
6267
6268 [providers.openrouter]
6269 api_key = "fixture-openrouter-key"
6270 model = "stealth/ox-alpha"
6271
6272 [providers.zai]
6273 api_key = "fixture-zai-key"
6274 model = "GLM-5.3"
6275
6276 [providers.xai]
6277 api_key = "fixture-xai-key"
6278 "#,
6279 )
6280 .expect("founder fixture");
6281 let mut app = App::new(crate::test_support::test_tui_options(workspace), &config);
6282 app.workspace = workspace.to_path_buf();
6283 app.api_provider = ProviderKind::Deepseek;
6284 app.provider_identity = Some(
6285 config
6286 .builtin_provider_identity(ProviderKind::Deepseek)
6287 .unwrap(),
6288 );
6289 app.auto_model = false;
6290 app.model = "deepseek-flash".to_string();
6291 app.provider_models
6292 .insert("deepseek".to_string(), "deepseek-flash".to_string());
6293 let now = chrono::Utc::now();
6294 let mut usage = app.route_usage.write().unwrap();
6295 usage.record("xai", "grok-4.7", now);
6296 usage.record("zai", "GLM-5.3", now - chrono::Duration::days(8));
6297 usage.record("deepseek", "deepseek-flash", now);
6298 drop(usage);
6299 (config, app)
6300 }
6301
6302 fn default_view(picker: &ModelPickerView) -> Vec<(String, String, Option<DefaultSection>)> {
6303 picker
6304 .visible_model_rows()
6305 .iter()
6306 .filter(|row| row.provider.is_some())
6307 .map(|row| {
6308 (
6309 row_provider_identity(row).unwrap_or_default().to_string(),
6310 row.id.clone(),
6311 row.default_rank.map(|(section, _)| section),
6312 )
6313 })
6314 .collect()
6315 }
6316
6317 #[test]
6318 fn default_view_ranks_by_use_and_keeps_the_long_tail_behind_search() {
6319 std::thread::Builder::new()
6320 .stack_size(16 * 1024 * 1024)
6321 .spawn(|| {
6322 let _env = crate::test_support::lock_test_env();
6323 let _live = crate::provider_lake::lock_live_snapshot();
6324 let root = tempfile::tempdir().unwrap();
6325 let home = root.path().join("home");
6326 let workspace = root.path().join("repo");
6327 std::fs::create_dir_all(&workspace).unwrap();
6328 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6329 crate::provider_catalog_live::reset_cache_for_test();
6330 crate::provider_lake::clear_live_snapshot();
6331 let (config, app) = founder_shaped_app(&home, &workspace);
6332
6333 let mut picker = ModelPickerView::new(&app, &config);
6334 assert_eq!(picker.view, ModelListView::Configured);
6335 let rows = default_view(&picker);
6336 let sections: Vec<_> = rows.iter().map(|(_, _, section)| *section).collect();
6337 let mut sorted = sections.clone();
6338 sorted.sort();
6339 assert_eq!(sections, sorted, "sections render in order: {rows:#?}");
6340 assert_eq!(
6341 rows[0],
6342 (
6343 "deepseek".to_string(),
6344 "deepseek-flash".to_string(),
6345 Some(DefaultSection::Current)
6346 ),
6347 "{rows:#?}"
6348 );
6349 assert_eq!(
6350 rows[1],
6351 (
6352 "xai".to_string(),
6353 "grok-4.6".to_string(),
6354 Some(DefaultSection::Pinned)
6355 ),
6356 "a Fleet pin shows although xai names no model: {rows:#?}"
6357 );
6358 let grok_46 = picker
6359 .visible_model_rows()
6360 .iter()
6361 .find(|row| row.id == "grok-4.6")
6362 .unwrap()
6363 .hint
6364 .clone();
6365 assert!(grok_46.starts_with("fleet · "), "{grok_46}");
6366 assert_eq!(
6367 rows[2],
6368 (
6369 "xai".to_string(),
6370 "grok-4.7".to_string(),
6371 Some(DefaultSection::Recent)
6372 ),
6373 "{rows:#?}"
6374 );
6375 assert_eq!(
6376 (rows[3].1.as_str(), rows[3].2),
6377 ("GLM-5.3", Some(DefaultSection::Recent)),
6378 "{rows:#?}"
6379 );
6380 for hidden in ["glm-5.2", "stealth/ox-alpha"] {
6381 assert!(
6382 !rows
6383 .iter()
6384 .any(|(_, id, _)| id.eq_ignore_ascii_case(hidden)),
6385 "{hidden} has no use and must stay out of the default view: {rows:#?}"
6386 );
6387 }
6388 for (identity, _, _) in &rows {
6389 assert!(
6390 rows.iter()
6391 .filter(|(other, _, section)| other == identity
6392 && *section == Some(DefaultSection::ProviderDefault))
6393 .count()
6394 <= 1,
6395 "at most one default per provider: {rows:#?}"
6396 );
6397 }
6398 // The active provider's roster stays one search away.
6399 assert!(
6400 !rows.iter().any(|(_, id, _)| id == "deepseek-v4-pro"),
6401 "{rows:#?}"
6402 );
6403
6404 picker.update_query("GLM-5.2".to_string());
6405 assert!(
6406 picker
6407 .visible_model_rows()
6408 .iter()
6409 .any(|row| row.provider == Some(ProviderKind::Zai)
6410 && row.id.eq_ignore_ascii_case("GLM-5.2")),
6411 "search still finds an unused model"
6412 );
6413 picker.update_query("ox-alpha".to_string());
6414 let ox = picker
6415 .visible_model_rows()
6416 .iter()
6417 .find(|row| row.id == "stealth/ox-alpha")
6418 .cloned()
6419 .expect("leftover config model is searchable");
6420 assert_eq!(ox.not_listed, None, "no roster, no verdict");
6421
6422 // The Fleet picker shares the builder: the edited row leads,
6423 // then pins, recent use and defaults in the same order.
6424 let fleet = ModelPickerView::new_for_fleet_route(
6425 &app,
6426 &config,
6427 FleetRouteTarget::Member(0),
6428 uuid::Uuid::nil(),
6429 FleetRouteSelection {
6430 provider: Some("xai".to_string()),
6431 model: Some("grok-4.6".to_string()),
6432 reasoning: None,
6433 allow_inherit: true,
6434 },
6435 );
6436 let fleet_rows = default_view(&fleet);
6437 let sections: Vec<_> = fleet_rows.iter().map(|(_, _, section)| *section).collect();
6438 let mut sorted = sections.clone();
6439 sorted.sort();
6440 assert_eq!(sections, sorted, "{fleet_rows:#?}");
6441 assert_eq!(
6442 fleet_rows[0],
6443 (
6444 "xai".to_string(),
6445 "grok-4.6".to_string(),
6446 Some(DefaultSection::Current)
6447 ),
6448 "{fleet_rows:#?}"
6449 );
6450 let recent = |rows: &[(String, String, Option<DefaultSection>)]| {
6451 rows.iter()
6452 .filter(|(_, _, section)| *section == Some(DefaultSection::Recent))
6453 .map(|(identity, id, _)| (identity.clone(), id.clone()))
6454 .collect::<Vec<_>>()
6455 };
6456 assert_eq!(
6457 recent(&fleet_rows),
6458 [
6459 ("deepseek".to_string(), "deepseek-flash".to_string()),
6460 ("xai".to_string(), "grok-4.7".to_string()),
6461 ("zai".to_string(), "GLM-5.3".to_string()),
6462 ],
6463 "the session's own route is ordinary recent use here: {fleet_rows:#?}"
6464 );
6465
6466 crate::provider_catalog_live::reset_cache_for_test();
6467 crate::provider_lake::clear_live_snapshot();
6468 })
6469 .unwrap()
6470 .join()
6471 .unwrap();
6472 }
6473
6474 #[test]
6475 fn not_listed_needs_a_fresh_roster_and_never_hides_the_row() {
6476 std::thread::Builder::new()
6477 .stack_size(16 * 1024 * 1024)
6478 .spawn(|| {
6479 let _env = crate::test_support::lock_test_env();
6480 let _live = crate::provider_lake::lock_live_snapshot();
6481 let root = tempfile::tempdir().unwrap();
6482 let home = root.path().join("home");
6483 let workspace = root.path().join("repo");
6484 std::fs::create_dir_all(&workspace).unwrap();
6485 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6486 crate::provider_catalog_live::reset_cache_for_test();
6487 crate::provider_lake::clear_live_snapshot();
6488 let (config, mut app) = founder_shaped_app(&home, &workspace);
6489 // OpenRouter was used recently, so its leftover model is its
6490 // default row and reaches the default view.
6491 app.note_route_used("openrouter", "stealth/ox-alpha");
6492
6493 let roster = |provider: ProviderKind, ids: &[&str], fetched_at: u64| {
6494 let base_url = config.base_url_for_route(
6495 &config
6496 .resolve_provider_selection_identity(provider.as_str())
6497 .unwrap(),
6498 );
6499 let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
6500 let _ = crate::provider_catalog_live::record_success(
6501 codewhale_config::catalog::ProviderCatalogDelta {
6502 provider: provider.as_str().to_string(),
6503 base_url_fingerprint: fingerprint.clone(),
6504 fetched_at,
6505 offerings: ids
6506 .iter()
6507 .map(|id| codewhale_config::catalog::CatalogOffering {
6508 provider: provider.as_str().to_string(),
6509 wire_model_id: (*id).to_string(),
6510 endpoint_key: "chat".to_string(),
6511 source: CatalogSource::Live {
6512 base_url_fingerprint: fingerprint.clone(),
6513 fetched_at,
6514 },
6515 ..Default::default()
6516 })
6517 .collect(),
6518 },
6519 );
6520 crate::provider_catalog_live::status_for_route(
6521 provider,
6522 provider.as_str(),
6523 &base_url,
6524 )
6525 };
6526 let now = u64::try_from(chrono::Utc::now().timestamp()).unwrap();
6527 // DeepSeek's roster is 15 days old and lacks the current model.
6528 assert!(matches!(
6529 roster(
6530 ProviderKind::Deepseek,
6531 &["deepseek-v4-flash", "deepseek-v4-pro"],
6532 now - 15 * 86_400
6533 ),
6534 CatalogStatus::Stale { .. }
6535 ));
6536 let picker = ModelPickerView::new(&app, &config);
6537 let rows = picker.visible_model_rows();
6538 let flash = rows.iter().find(|row| row.id == "deepseek-flash").unwrap();
6539 assert_eq!(flash.not_listed, None, "a stale roster gives no verdict");
6540 let ox = rows
6541 .iter()
6542 .find(|row| row.id == "stealth/ox-alpha")
6543 .expect("recently used route's model is in the default view");
6544 assert_eq!(ox.not_listed, None, "no OpenRouter roster yet");
6545 drop(rows);
6546
6547 assert_eq!(
6548 roster(ProviderKind::Openrouter, &["openai/gpt-5"], now),
6549 CatalogStatus::Fresh
6550 );
6551 let picker = ModelPickerView::new(&app, &config);
6552 let rows = picker.visible_model_rows();
6553 let ox = rows
6554 .iter()
6555 .find(|row| row.id == "stealth/ox-alpha")
6556 .expect("a not-listed row is dimmed, never hidden");
6557 let checked = chrono::Utc::now().format("%Y-%m-%d").to_string();
6558 assert_eq!(
6559 ox.not_listed,
6560 Some(NotListed {
6561 provider: ProviderKind::Openrouter
6562 .provider()
6563 .display_name()
6564 .to_string(),
6565 checked: checked.clone(),
6566 })
6567 );
6568 assert!(ox.selectable, "still selectable");
6569 drop(rows);
6570 let text = render_text(&picker, 160, 40);
6571 assert!(
6572 text.contains(&format!("(checked {checked})")),
6573 "the reason is rendered: {text}"
6574 );
6575 assert_eq!(
6576 config
6577 .provider_config_for(
6578 &config.test_identity_for_kind(ProviderKind::Openrouter)
6579 )
6580 .and_then(|entry| entry.model.as_deref()),
6581 Some("stealth/ox-alpha"),
6582 "config is never rewritten"
6583 );
6584
6585 crate::provider_catalog_live::reset_cache_for_test();
6586 crate::provider_lake::clear_live_snapshot();
6587 })
6588 .unwrap()
6589 .join()
6590 .unwrap();
6591 }
6592 }
6593
6593 lines RUST