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