use std::cell::RefCell;
use std::collections::BTreeMap;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Widget},
};
use codewhale_config::catalog::CatalogSource;
use codewhale_config::model_reference::ModelReferenceCard;
use codewhale_config::pricing::OfferingPricing;
use crate::codex_model_cache::{
self, CodexModelCacheFreshness, CodexModelMetadata, CodexModelRoster,
};
use crate::config::{ApiProvider, Config, DEEPSEEK_ALIAS_REPLACEMENT};
use crate::localization::{Locale, MessageId, tr};
use crate::model_profile::{
CapabilityOverride, SupportState, resolved_capability_profile_for_route_with_overrides,
resolved_capability_profile_with_overrides,
};
use crate::model_registry;
use crate::models_dev_live::{self, ModelsDevFreshness};
use crate::palette;
use crate::provider_lake::{
all_catalog_models_for_provider, catalog_offering_for_model, configured_providers,
};
use crate::settings::PinnedModel;
use crate::tui::app::{App, ReasoningEffort};
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, ListDetailLayout, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
render_underwater_surface,
};
const DEFAULT_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Auto,
ReasoningEffort::Off,
ReasoningEffort::High,
ReasoningEffort::Max,
];
const DEEPSEEK_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Auto,
ReasoningEffort::Off,
ReasoningEffort::Low,
ReasoningEffort::High,
ReasoningEffort::Max,
];
const KIMI_CODE_K3_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Auto,
ReasoningEffort::Off,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
];
const CODEX_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
];
const AUTO_MODEL_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Auto,
ReasoningEffort::Off,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelListView {
Configured,
Catalog,
Recent,
Coding,
Cheap,
LongContext,
}
impl ModelListView {
const ALL: [Self; 6] = [
Self::Configured,
Self::Catalog,
Self::Recent,
Self::Coding,
Self::Cheap,
Self::LongContext,
];
fn next(self) -> Self {
let idx = Self::ALL.iter().position(|view| *view == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
fn from_memory_name(name: &str) -> Option<Self> {
match name {
"configured" => Some(Self::Configured),
"catalog" => Some(Self::Catalog),
"recent" => Some(Self::Recent),
"coding" => Some(Self::Coding),
"cheap" => Some(Self::Cheap),
"long_context" => Some(Self::LongContext),
_ => None,
}
}
fn memory_name(self) -> &'static str {
match self {
Self::Configured => "configured",
Self::Catalog => "catalog",
Self::Recent => "recent",
Self::Coding => "coding",
Self::Cheap => "cheap",
Self::LongContext => "long_context",
}
}
fn title_label(self) -> &'static str {
match self {
Self::Configured => "configured",
Self::Catalog => "catalog",
Self::Recent => "recent",
Self::Coding => "coding",
Self::Cheap => "cheap",
Self::LongContext => "long ctx",
}
}
fn is_discoverability(self) -> bool {
!matches!(self, Self::Configured)
}
fn browses_all_providers(self) -> bool {
self.is_discoverability()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pane {
Model,
Effort,
}
#[derive(Debug, Clone, Copy)]
struct PaneRenderState {
pane: Pane,
selected: usize,
focused: bool,
}
pub struct ModelPickerView {
initial_model: String,
previous_model: String,
initial_provider: ApiProvider,
initial_effort: ReasoningEffort,
selected_effort_request: ReasoningEffort,
active_accepts_custom_model_ids: bool,
query: String,
selected_model_idx: usize,
selected_effort_idx: usize,
focus: Pane,
show_custom_model_row: bool,
model_rows: Vec<ModelPickerRow>,
route_config: Config,
provider_health: crate::provider_readiness::ProviderReadinessSnapshot,
view: ModelListView,
configured_providers: Vec<ApiProvider>,
row_hitboxes: RefCell<Vec<(Rect, Pane, usize)>>,
last_mouse_selected: Option<(Pane, usize)>,
locale: Locale,
pinned_models: Vec<PinnedModel>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ModelPickerRow {
id: String,
provider: Option<ApiProvider>,
provider_identity: Option<String>,
hint: String,
metadata: EffectivePickerMetadata,
selectable: bool,
blocked_reason: Option<String>,
enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct EffectivePickerMetadata {
context_window: Option<u32>,
max_output: Option<u32>,
tool_calls: Option<bool>,
reasoning: bool,
vision: SupportState,
pricing: PickerPricing,
source: Option<CatalogSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
enum PickerPricing {
Unavailable,
Known(String),
#[default]
Unknown,
}
impl ModelPickerView {
#[must_use]
pub fn new(app: &App, config: &Config) -> Self {
let initial_model = if app.auto_model {
"auto".to_string()
} else {
picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids())
.to_string()
};
let previous_model = if app.auto_model {
"auto".to_string()
} else {
app.model.clone()
};
let model_rows = picker_model_rows_for_app(app, config);
let configured_providers: Vec<_> = configured_providers(config, app.api_provider)
.into_iter()
.filter(|provider| *provider != app.api_provider)
.collect();
let mut default_visible_rows: Vec<_> = model_rows
.iter()
.filter(|row| model_row_visible_in_view(row, ModelListView::Configured))
.collect();
sort_model_rows_for_view(
&mut default_visible_rows,
ModelListView::Configured,
&app.pinned_models,
);
let mut selected_model_idx = default_visible_rows.iter().position(|row| {
row.id == initial_model
&& (row.provider.is_none() || row.provider == Some(app.api_provider))
});
let show_custom_model_row = selected_model_idx.is_none();
if show_custom_model_row {
selected_model_idx = Some(default_visible_rows.len());
}
let selected_model_idx = selected_model_idx.unwrap_or(0);
let initial_effort = app
.reasoning_effort_preference
.unwrap_or(ReasoningEffort::Auto);
let selected_effort_request = app
.reasoning_effort_preference
.unwrap_or(app.reasoning_effort);
let effort_rows = picker_efforts_for_route(
app.api_provider,
&config.deepseek_base_url(),
&initial_model,
app.auto_model,
);
let normalized = normalize_picker_effort(
selected_effort_request,
app.api_provider,
&config.deepseek_base_url(),
&initial_model,
app.auto_model,
);
let selected_effort_idx = effort_rows
.iter()
.position(|e| *e == normalized)
.unwrap_or_else(|| {
default_picker_effort_idx(
app.api_provider,
&config.deepseek_base_url(),
&initial_model,
app.auto_model,
)
});
let mut view = Self {
initial_model,
previous_model,
initial_provider: app.api_provider,
initial_effort,
selected_effort_request,
active_accepts_custom_model_ids: app.accepts_custom_model_ids(),
query: String::new(),
selected_model_idx,
selected_effort_idx,
focus: Pane::Model,
show_custom_model_row,
model_rows,
route_config: config.clone(),
provider_health: app.provider_health.clone(),
view: ModelListView::Configured,
configured_providers,
row_hitboxes: RefCell::new(Vec::new()),
last_mouse_selected: None,
locale: app.ui_locale,
pinned_models: app.pinned_models.clone(),
};
view.restore_memory(app.model_picker_memory.as_ref());
view
}
fn restore_memory(&mut self, memory: Option<&crate::tui::app::ModelPickerMemory>) {
let Some(memory) = memory else {
return;
};
if let Some(view_name) = memory.view.as_deref() {
if let Some(view) = ModelListView::from_memory_name(view_name) {
self.view = view;
}
} else if memory.catalog_view {
self.view = ModelListView::Catalog;
}
if let Some(remembered_id) = memory.selected_row_id.as_deref() {
let position = self
.visible_model_rows()
.iter()
.position(|row| row.id == remembered_id);
if let Some(position) = position {
self.selected_model_idx = position;
self.select_effort_for_current_model();
}
}
self.clamp_model_selection();
}
#[cfg(test)]
fn visible_model_ids(&self) -> Vec<&str> {
self.visible_model_rows()
.iter()
.map(|row| row.id.as_str())
.collect()
}
fn visible_model_rows(&self) -> Vec<&ModelPickerRow> {
let query = self.query.trim();
let mut rows: Vec<&ModelPickerRow> = self
.model_rows
.iter()
.filter(|row| {
if query.is_empty() {
model_row_visible_in_view(row, self.view)
} else {
model_row_matches_query(row, query, self.initial_provider)
}
})
.collect();
if query.is_empty() {
sort_model_rows_for_view(&mut rows, self.view, &self.pinned_models);
} else {
let query_lower = query.to_ascii_lowercase();
let initial_provider = self.initial_provider;
rows.sort_by(|a, b| {
let rank = |row: &ModelPickerRow| {
let provider_matches = row.provider.is_some_and(|provider| {
row.provider_identity.as_deref().is_some_and(|identity| {
identity.to_ascii_lowercase().contains(&query_lower)
}) || provider
.as_str()
.to_ascii_lowercase()
.contains(&query_lower)
|| provider
.display_name()
.to_ascii_lowercase()
.contains(&query_lower)
});
let id = row.id.to_ascii_lowercase();
let id_rank = if id == query_lower {
0
} else if id.starts_with(&query_lower) {
1
} else {
2
};
let provider_rank =
if row.provider.is_none() || row.provider == Some(initial_provider) {
0
} else {
1
};
(
if provider_matches { 0 } else { 1 },
id_rank,
provider_rank,
id,
)
};
rank(a).cmp(&rank(b))
});
}
rows
}
fn model_row_count(&self) -> usize {
let rows = self.visible_model_rows();
rows.len() + usize::from(self.custom_model_row_for_visible(&rows).is_some())
}
fn resolved_model(&self) -> String {
let rows = self.visible_model_rows();
if self.selected_model_idx < rows.len() {
return rows[self.selected_model_idx].id.clone();
}
self.custom_model_row()
.map(|(model, _)| model)
.unwrap_or_else(|| self.initial_model.clone())
}
fn selected_model_is_selectable(&self) -> bool {
let rows = self.visible_model_rows();
if let Some(row) = rows.get(self.selected_model_idx) {
return row.selectable;
}
self.custom_model_row().is_some_and(|(model, provider)| {
crate::provider_readiness::resolve_for_model(
&self.route_config,
provider,
&model,
&self.provider_health,
)
.can_attempt()
})
}
fn explain_unselectable_selection(&self) -> ViewAction {
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let reason = if row.hint.trim().is_empty() {
"This model is not available with the current provider credentials.".to_string()
} else {
row.hint.clone()
};
let message = format!(
"🔒 {} is locked — {reason}. Open /provider to authenticate, then refresh.",
row.id
);
if let Some(provider) = row.provider {
return ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth {
provider,
model: row.id.clone(),
reason: message,
});
}
ViewAction::Emit(ViewEvent::StatusMessage { message })
}
fn resolved_provider(&self) -> Option<ApiProvider> {
let rows = self.visible_model_rows();
if self.selected_model_idx < rows.len() {
return rows[self.selected_model_idx].provider;
}
self.custom_model_row()
.map(|(_, provider)| provider)
.or(Some(self.initial_provider))
}
fn resolved_effort(&self) -> ReasoningEffort {
let efforts = self.current_efforts();
efforts[self
.selected_effort_idx
.min(efforts.len().saturating_sub(1))]
}
fn current_efforts(&self) -> Vec<ReasoningEffort> {
let provider = self.resolved_provider().unwrap_or(self.initial_provider);
let model = self.resolved_model();
let base_url = self.resolved_base_url_for_provider(provider, &model);
picker_efforts_for_route(
provider,
&base_url,
&model,
model.trim().eq_ignore_ascii_case("auto"),
)
}
fn resolved_base_url_for_provider(&self, provider: ApiProvider, model: &str) -> String {
crate::route_runtime::resolve_runtime_route(&self.route_config, provider, Some(model))
.map(|route| route.candidate.endpoint().base_url.clone())
.unwrap_or_else(|_| provider.default_base_url().to_string())
}
fn custom_model_row(&self) -> Option<(String, ApiProvider)> {
let rows = self.visible_model_rows();
self.custom_model_row_for_visible(&rows)
}
fn custom_model_row_for_visible(
&self,
visible_rows: &[&ModelPickerRow],
) -> Option<(String, ApiProvider)> {
let query = self.query.trim();
if query.is_empty() {
return self
.show_custom_model_row
.then(|| (self.initial_model.clone(), self.initial_provider));
}
if let Some((provider, model)) = self.provider_qualified_custom_query(query) {
if visible_rows.iter().any(|row| {
row.provider == Some(provider) && row.id.eq_ignore_ascii_case(model.trim())
}) {
return None;
}
if self.provider_accepts_custom_model(provider, &model) {
return Some((model, provider));
}
return None;
}
if !self.active_accepts_custom_model_ids {
return None;
}
if visible_rows.iter().any(|row| {
row.provider == Some(self.initial_provider) && row.id.eq_ignore_ascii_case(query)
}) {
return None;
}
Some((query.to_string(), self.initial_provider))
}
fn provider_qualified_custom_query(&self, query: &str) -> Option<(ApiProvider, String)> {
for (provider_key, model) in provider_query_splits(query) {
let Some(provider) = ApiProvider::parse(provider_key) else {
continue;
};
if provider != self.initial_provider
&& !self.view.browses_all_providers()
&& !self.configured_providers.contains(&provider)
{
continue;
}
let model = model.trim();
if model.is_empty() {
continue;
}
return Some((provider, model.to_string()));
}
None
}
fn provider_accepts_custom_model(&self, provider: ApiProvider, model: &str) -> bool {
(provider == self.initial_provider && self.active_accepts_custom_model_ids)
|| crate::config::normalize_model_name_for_provider(provider, model).is_some()
}
fn clamp_model_selection(&mut self) {
let count = self.model_row_count();
if count == 0 {
self.selected_model_idx = 0;
} else if self.selected_model_idx >= count {
self.selected_model_idx = count - 1;
}
}
fn update_query(&mut self, next: String) {
self.query = next;
self.selected_model_idx = 0;
self.clamp_model_selection();
self.select_effort_for_current_model();
}
fn select_effort_for_current_model(&mut self) {
let provider = self.resolved_provider().unwrap_or(self.initial_provider);
let model = self.resolved_model();
let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
let base_url = self.resolved_base_url_for_provider(provider, &model);
let normalized = normalize_picker_effort(
self.selected_effort_request,
provider,
&base_url,
&model,
model_is_auto,
);
self.selected_effort_idx =
picker_efforts_for_route(provider, &base_url, &model, model_is_auto)
.iter()
.position(|candidate| *candidate == normalized)
.unwrap_or_else(|| {
default_picker_effort_idx(provider, &base_url, &model, model_is_auto)
});
}
fn move_up(&mut self) -> bool {
match self.focus {
Pane::Model => {
if self.selected_model_idx > 0 {
self.selected_model_idx -= 1;
self.select_effort_for_current_model();
return true;
}
}
Pane::Effort => {
if self.selected_effort_idx > 0 {
self.selected_effort_idx -= 1;
self.selected_effort_request = self.resolved_effort();
return true;
}
}
}
false
}
fn move_down(&mut self) -> bool {
match self.focus {
Pane::Model => {
let max = self.model_row_count().saturating_sub(1);
if self.selected_model_idx < max {
self.selected_model_idx += 1;
self.select_effort_for_current_model();
return true;
}
}
Pane::Effort => {
let max = self.current_efforts().len().saturating_sub(1);
if self.selected_effort_idx < max {
self.selected_effort_idx += 1;
self.selected_effort_request = self.resolved_effort();
return true;
}
}
}
false
}
fn toggle_focus(&mut self) {
self.focus = match self.focus {
Pane::Model => Pane::Effort,
Pane::Effort => Pane::Model,
};
}
fn toggle_view(&mut self) {
self.view = self.view.next();
self.selected_model_idx = 0;
self.clamp_model_selection();
self.select_effort_for_current_model();
}
fn build_event(&self) -> ViewEvent {
self.build_event_with_startup_default(false)
}
fn build_event_with_startup_default(&self, save_as_startup_default: bool) -> ViewEvent {
let resolved_provider = self.resolved_provider().unwrap_or(self.initial_provider);
let provider = (resolved_provider != self.initial_provider).then_some(resolved_provider);
let provider_id = (resolved_provider == ApiProvider::Custom)
.then(|| self.route_config.provider_identity_for(resolved_provider));
ViewEvent::ModelPickerApplied {
model: self.resolved_model(),
provider,
provider_id,
effort: self.selected_effort_request,
previous_model: self.previous_model.clone(),
previous_effort: self.initial_effort,
save_as_startup_default,
}
}
fn render_pane(
&self,
area: Rect,
buf: &mut Buffer,
title: &str,
rows: Vec<PaneRow>,
state: PaneRenderState,
) {
let visible_height = usize::from(area.height.saturating_sub(1));
let (start, end) = visible_row_window(state.selected, rows.len(), visible_height);
let title = if rows.len() > visible_height && visible_height > 0 {
if start + 1 == end {
format!(" {title} {}/{} ", end, rows.len())
} else {
format!(" {title} {}-{}/{} ", start + 1, end, rows.len())
}
} else {
format!(" {title} ")
};
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(area, buf);
let title_area = Rect { height: 1, ..area };
Paragraph::new(Line::from(vec![
Span::styled(
if state.focused { "▸ " } else { " " },
Style::default().fg(palette::WHALE_INFO),
),
Span::styled(
title,
Style::default()
.fg(if state.focused {
palette::WHALE_INFO
} else {
palette::TEXT_PRIMARY
})
.bold(),
),
]))
.render(title_area, buf);
let inner = Rect {
y: area.y.saturating_add(1),
height: area.height.saturating_sub(1),
..area
};
let columns = ModelRowColumns::for_page(&rows[start.min(rows.len())..end.min(rows.len())]);
let mut lines = Vec::with_capacity(end.saturating_sub(start));
let pane_height = usize::from(inner.height);
for (idx, row) in rows.iter().enumerate().skip(start).take(end - start) {
if lines.len() >= pane_height {
break;
}
let is_selected = idx == state.selected;
let locked = state.pane == Pane::Model
&& self
.visible_model_rows()
.get(idx)
.is_some_and(|row| !row.selectable);
let marker = if locked {
"🔒"
} else if is_selected {
crate::tui::glyphs::SELECTION
} else if row.active {
crate::tui::glyphs::CURRENT
} else {
" "
};
let label_style = if is_selected && !locked {
menu_style::selected_row_style()
} else if is_selected && locked {
menu_style::disabled_selected_row_style()
} else if locked {
Style::default()
.fg(palette::TEXT_MUTED)
.add_modifier(Modifier::DIM)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let hint_style = if is_selected && !locked {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
if let Some(family) = row.family.as_deref() {
let prev_family = rows
.get(idx.wrapping_sub(1))
.and_then(|prev| prev.family.as_deref());
let prev_provider = rows
.get(idx.wrapping_sub(1))
.map(|prev| prev.route.as_str());
if prev_family != Some(family) || prev_provider != Some(row.route.as_str()) {
lines.push(Line::from(Span::styled(
format!(" ─ {family}"),
Style::default().fg(palette::TEXT_DIM),
)));
}
}
let row_y = inner.y.saturating_add(lines.len() as u16);
self.row_hitboxes.borrow_mut().push((
Rect::new(inner.x, row_y, inner.width, 1),
state.pane,
idx,
));
let spans = picker_row_spans(
row,
marker,
usize::from(inner.width),
columns,
label_style,
hint_style,
);
lines.push(Line::from(spans));
}
if rows.is_empty() {
let message = if self.query.is_empty() {
tr(self.locale, MessageId::RouteNoModels).into_owned()
} else {
tr(self.locale, MessageId::RouteNoModelMatch).replace("{query}", &self.query)
};
lines.push(Line::from(Span::styled(
message,
Style::default().fg(palette::TEXT_MUTED),
)));
}
if lines.len() > usize::from(inner.height) {
lines.truncate(usize::from(inner.height));
}
Paragraph::new(lines).render(inner, buf);
}
}
fn visible_row_window(selected: usize, total: usize, viewport_height: usize) -> (usize, usize) {
if total == 0 || viewport_height == 0 {
return (0, 0);
}
let visible = viewport_height.min(total);
let mut start = selected.saturating_sub(visible / 2);
if start + visible > total {
start = total.saturating_sub(visible);
}
(start, start + visible)
}
const EFFORT_PANE_WIDTH: u16 = 30;
fn widen_model_pane(layout: ListDetailLayout) -> ListDetailLayout {
if layout.stacked {
return layout;
}
let gap = layout
.detail
.x
.saturating_sub(layout.list.x.saturating_add(layout.list.width));
let total = layout.list.width + gap + layout.detail.width;
let detail_width = layout.detail.width.min(EFFORT_PANE_WIDTH);
let list_width = total.saturating_sub(gap + detail_width);
ListDetailLayout {
list: Rect {
width: list_width,
..layout.list
},
detail: Rect {
x: layout.list.x + list_width + gap,
width: detail_width,
..layout.detail
},
stacked: false,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct PaneRow {
primary: String,
route: String,
meta: Vec<String>,
family: Option<String>,
active: bool,
}
impl PaneRow {
fn effort(primary: String, meta: String) -> Self {
Self {
primary,
route: String::new(),
meta: if meta.is_empty() {
Vec::new()
} else {
vec![meta]
},
family: None,
active: false,
}
}
fn meta_width(&self) -> usize {
unicode_width::UnicodeWidthStr::width(self.meta.join(" · ").as_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ModelRowColumns {
primary: usize,
route: usize,
meta: usize,
}
const MARKER_CELL_WIDTH: usize = 2;
const ROW_PREFIX_WIDTH: usize = MARKER_CELL_WIDTH + 2;
const COLUMN_GAP: usize = 2;
const MIN_ROUTE_WIDTH: usize = 6;
const MIN_META_WIDTH: usize = 4;
impl ModelRowColumns {
fn for_page(rows: &[PaneRow]) -> Self {
let widest = |pick: fn(&PaneRow) -> usize| rows.iter().map(pick).max().unwrap_or(0);
Self {
primary: widest(|row| unicode_width::UnicodeWidthStr::width(row.primary.as_str())),
route: widest(|row| unicode_width::UnicodeWidthStr::width(row.route.as_str())),
meta: widest(PaneRow::meta_width),
}
}
fn resolve(self, width: usize) -> Self {
let available = width.saturating_sub(ROW_PREFIX_WIDTH);
if available == 0 {
return Self::default();
}
let gaps = COLUMN_GAP * (usize::from(self.route > 0) + usize::from(self.meta > 0));
let content = available.saturating_sub(gaps);
if content == 0 {
return Self {
primary: available,
route: 0,
meta: 0,
};
}
if self.primary + self.route + self.meta <= content {
return self;
}
if self.route == 0 {
let primary = self.primary.min(content);
let meta = self.meta.min(content.saturating_sub(primary));
return Self {
primary,
route: 0,
meta: if meta < MIN_META_WIDTH { 0 } else { meta },
};
}
let mut meta = if self.meta == 0 {
0
} else {
self.meta
.min((content / 4).max(MIN_META_WIDTH.min(content)))
};
let after_meta = content.saturating_sub(meta);
let mut route = if self.route == 0 {
0
} else {
self.route
.min((after_meta / 3).max(MIN_ROUTE_WIDTH.min(after_meta)))
};
let mut primary = after_meta.saturating_sub(route);
if primary > self.primary {
let mut slack = primary - self.primary;
primary = self.primary;
for (column, natural) in [(&mut meta, self.meta), (&mut route, self.route)] {
let gain = slack.min(natural.saturating_sub(*column));
*column += gain;
slack -= gain;
}
primary += slack;
}
Self {
primary,
route,
meta,
}
}
}
fn fit_identifier(text: &str, width: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if UnicodeWidthStr::width(text) <= width {
return text.to_string();
}
if width < 8 {
return fit_text(text, width);
}
let budget = width - 1;
let tail_budget = (budget * 3) / 5;
let head_budget = budget - tail_budget;
let mut head = String::new();
let mut used = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > head_budget {
break;
}
used += ch_width;
head.push(ch);
}
let mut tail: Vec<char> = Vec::new();
let mut used = 0usize;
for ch in text.chars().rev() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > tail_budget {
break;
}
used += ch_width;
tail.push(ch);
}
tail.reverse();
let mut out = head;
out.push('…');
out.extend(tail);
out
}
fn picker_row_spans<'a>(
row: &'a PaneRow,
marker: &'static str,
width: usize,
columns: ModelRowColumns,
label_style: Style,
hint_style: Style,
) -> Vec<Span<'a>> {
use unicode_width::UnicodeWidthStr;
let columns = columns.resolve(width);
let marker_pad = MARKER_CELL_WIDTH.saturating_sub(UnicodeWidthStr::width(marker));
let mut spans = vec![
Span::styled(" ", label_style),
Span::styled(marker, label_style),
Span::styled(" ".repeat(marker_pad + 1), label_style),
];
let mut used = ROW_PREFIX_WIDTH;
let primary = fit_identifier(&row.primary, columns.primary.max(1));
used += UnicodeWidthStr::width(primary.as_str());
spans.push(Span::styled(primary, label_style));
let pad_to = |spans: &mut Vec<Span<'a>>, used: &mut usize, target: usize| {
if *used < target {
spans.push(Span::styled(" ".repeat(target - *used), label_style));
*used = target;
}
};
if columns.route > 0 && !row.route.is_empty() {
pad_to(&mut spans, &mut used, ROW_PREFIX_WIDTH + columns.primary);
spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
used += COLUMN_GAP;
let route = fit_identifier(&row.route, columns.route);
used += UnicodeWidthStr::width(route.as_str());
spans.push(Span::styled(route, hint_style));
}
if !row.meta.is_empty() {
let column_edge = if columns.route > 0 && !row.route.is_empty() {
ROW_PREFIX_WIDTH + columns.primary + COLUMN_GAP + columns.route
} else {
ROW_PREFIX_WIDTH + columns.primary
};
let remaining = width
.saturating_sub(column_edge)
.saturating_sub(COLUMN_GAP)
.min(columns.meta.max(MIN_META_WIDTH));
let meta = fit_meta_chips(&row.meta, remaining);
if !meta.is_empty() {
pad_to(&mut spans, &mut used, column_edge);
spans.push(Span::styled(" ".repeat(COLUMN_GAP), label_style));
spans.push(Span::styled(meta, hint_style));
}
}
spans
}
fn fit_text(text: &str, width: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if UnicodeWidthStr::width(text) <= width {
return text.to_string();
}
if width == 0 {
return String::new();
}
if width <= 3 {
return ".".repeat(width);
}
let mut out = String::new();
let target = width - 3;
let mut used = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > target {
break;
}
used += ch_width;
out.push(ch);
}
out.push_str("...");
out
}
#[cfg(test)]
fn picker_model_ids_for_provider(provider: ApiProvider) -> Vec<String> {
let mut models = vec!["auto".to_string()];
for id in provider_catalog_model_ids(provider) {
if id != "auto" && !models.iter().any(|m| m.eq_ignore_ascii_case(&id)) {
models.push(id);
}
}
models
}
pub(crate) fn provider_scoped_model_completion_ids(app: &App) -> Vec<String> {
provider_scoped_model_ids_for_app(app, true)
}
fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow> {
let mut rows = Vec::new();
let auto_hint = auto_picker_hint(app, config);
push_auto_model_row(&mut rows, app, config, &auto_hint);
let codex_roster = codex_model_cache::model_roster();
let mut active_model_ids = if app.api_provider == ApiProvider::OpenaiCodex {
let mut models = vec!["auto".to_string()];
for id in codex_roster.model_ids() {
push_model_id(&mut models, &id);
}
if let Some(model) = app
.provider_models
.get(app.provider_identity_for_persistence())
.map(|model| model.trim())
.filter(|model| !model.is_empty())
{
push_model_id(
&mut models,
picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
);
}
models
} else {
provider_scoped_model_ids_for_app(app, false)
};
push_configured_provider_model(&mut active_model_ids, config, app.api_provider);
push_provider_model_rows(
&mut rows,
app.api_provider,
active_model_ids,
app.api_provider,
config,
&codex_roster,
&app.provider_health,
);
for provider in ApiProvider::sorted_for_display() {
if provider == app.api_provider {
continue;
}
let mut model_ids = if provider == ApiProvider::OpenaiCodex {
codex_roster.model_ids()
} else {
provider_catalog_model_ids(provider)
};
if let Some(model) = app
.provider_models
.get(provider.as_str())
.map(|model| model.trim())
.filter(|model| !model.is_empty())
{
push_model_id(
&mut model_ids,
picker_visible_model_id(
provider,
model,
config.model_ids_pass_through_for_provider(provider),
),
);
}
push_configured_provider_model(&mut model_ids, config, provider);
push_provider_model_rows(
&mut rows,
provider,
model_ids,
app.api_provider,
config,
&codex_roster,
&app.provider_health,
);
}
let active_custom_identity = (app.api_provider == ApiProvider::Custom)
.then(|| app.provider_identity_for_persistence().to_string());
for row in &mut rows {
if row.provider == Some(ApiProvider::Custom) {
row.provider_identity = active_custom_identity.clone();
}
}
for row in &mut rows {
row.enabled = model_row_enabled_for_app(app, config, row);
if let Some(pin) = app.pinned_models.iter().find(|pin| {
row_provider_identity(row)
.is_some_and(|provider| provider.eq_ignore_ascii_case(&pin.provider))
&& row.id.eq_ignore_ascii_case(&pin.model)
}) {
let label = pin.label.as_deref().unwrap_or("pinned");
row.hint = format!(
"{label} · exact {} / {} · {}",
pin.provider, pin.model, row.hint
);
}
}
for pin in &app.pinned_models {
let provider = ApiProvider::parse(&pin.provider).unwrap_or(ApiProvider::Custom);
if rows.iter().any(|row| {
row_provider_identity(row)
.is_some_and(|identity| identity.eq_ignore_ascii_case(&pin.provider))
&& row.id.eq_ignore_ascii_case(&pin.model)
}) {
continue;
}
let metadata = effective_picker_metadata(config, Some(provider), &pin.model);
rows.push(ModelPickerRow {
id: pin.model.clone(),
provider: Some(provider),
provider_identity: Some(pin.provider.clone()),
hint: format!(
"stale pinned · exact {} / {} · unavailable; repair or remove",
pin.provider, pin.model
),
metadata,
selectable: false,
blocked_reason: Some("stale pin".to_string()),
enabled: true,
});
}
rows
}
fn model_row_enabled_for_app(app: &App, config: &Config, row: &ModelPickerRow) -> bool {
let Some(provider) = row.provider else {
return true;
};
if provider == app.api_provider {
let current =
picker_visible_model_id(app.api_provider, &app.model, app.accepts_custom_model_ids());
if row.id.eq_ignore_ascii_case(current) {
return true;
}
}
let provider_identity = if provider == app.api_provider {
app.provider_identity_for_persistence()
} else {
provider.as_str()
};
if app.provider_model_is_enabled(provider_identity, &row.id)
|| app
.provider_models
.get(provider_identity)
.is_some_and(|model| model.eq_ignore_ascii_case(&row.id))
{
return true;
}
config
.provider_config_for(provider)
.and_then(|entry| entry.model.as_deref())
.is_some_and(|model| model.eq_ignore_ascii_case(&row.id))
}
fn push_provider_model_rows(
rows: &mut Vec<ModelPickerRow>,
provider: ApiProvider,
model_ids: Vec<String>,
active_provider: ApiProvider,
config: &Config,
codex_roster: &CodexModelRoster,
provider_health: &crate::provider_readiness::ProviderReadinessSnapshot,
) {
for id in model_ids {
if id == "auto" {
continue;
}
let readiness =
crate::provider_readiness::resolve_for_model(config, provider, &id, provider_health);
let selectable = readiness.can_attempt();
let readiness_label = readiness.label();
let roster_entry = if provider == ApiProvider::OpenaiCodex {
codex_roster.metadata_for(&id)
} else {
None
};
let codex_metadata = if codex_roster.freshness == CodexModelCacheFreshness::Fresh {
roster_entry
} else {
None
};
let codex_freshness = roster_entry.map(|_| codex_roster.freshness);
let metadata =
effective_picker_metadata_with_codex(config, Some(provider), &id, codex_metadata);
let mut hint = render_picker_model_hint(&id, Some(provider), &metadata, codex_freshness);
hint = format!("{readiness_label} · {hint}");
if provider != active_provider {
hint = format!("switch route · {hint}");
}
let blocked_reason = (!selectable).then(|| readiness_label.to_string());
push_model_row(
rows,
id.clone(),
Some(provider),
hint,
metadata,
selectable,
blocked_reason,
);
}
}
fn push_auto_model_row(rows: &mut Vec<ModelPickerRow>, app: &App, config: &Config, hint: &str) {
let readiness = crate::provider_readiness::resolve_for_model(
config,
app.api_provider,
"auto",
&app.provider_health,
);
let metadata = effective_picker_metadata(config, None, "auto");
let selectable = readiness.can_attempt();
let blocked_reason = (!selectable).then(|| readiness.label().to_string());
push_model_row(
rows,
"auto".to_string(),
None,
format!("{} · {hint}", readiness.label()),
metadata,
selectable,
blocked_reason,
);
}
fn auto_picker_hint(app: &App, config: &Config) -> String {
let inventory = crate::model_inventory::ModelInventory::from_config(config);
let hint_id = match (inventory.router_available, inventory.cross_provider_auto) {
(true, true) => MessageId::ModelPickerAutoNetworkHint,
(true, false) => MessageId::ModelPickerAutoNetworkActiveProviderHint,
(false, _) => MessageId::ModelPickerAutoLocalHint,
};
let mut hint = app
.tr(hint_id)
.into_owned()
.replace("{provider}", inventory.router_provider.display_name())
.replace("{model}", &inventory.router_model);
if let (Some(provider), Some(model)) = (
app.last_effective_provider,
app.last_effective_model.as_deref(),
) {
let provider_label = if provider == ApiProvider::Custom {
app.last_effective_provider_identity
.as_deref()
.unwrap_or_else(|| app.provider_identity_for_persistence())
} else {
provider.display_name()
};
let last = app
.tr(MessageId::ModelPickerAutoLastRoute)
.replace("{provider}", provider_label)
.replace("{model}", model);
hint.push_str(" · ");
hint.push_str(&last);
}
hint
}
fn push_configured_provider_model(
models: &mut Vec<String>,
config: &Config,
provider: ApiProvider,
) {
if let Some(model) = config
.provider_config_for(provider)
.and_then(|entry| entry.model.as_deref())
.map(str::trim)
.filter(|model| !model.is_empty())
{
push_model_id(
models,
picker_visible_model_id(
provider,
model,
config.model_ids_pass_through_for_provider(provider),
),
);
}
}
fn provider_catalog_model_ids(provider: ApiProvider) -> Vec<String> {
let mut models = Vec::new();
for id in all_catalog_models_for_provider(provider) {
push_model_id(&mut models, picker_visible_model_id(provider, &id, false));
}
models
}
fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) -> Vec<String> {
let mut models = Vec::new();
push_model_id(&mut models, "auto");
for id in provider_catalog_model_ids(app.api_provider) {
push_model_id(&mut models, &id);
}
if let Some(model) = app
.provider_models
.get(app.provider_identity_for_persistence())
.map(|model| model.trim())
.filter(|model| !model.is_empty())
{
push_model_id(
&mut models,
picker_visible_model_id(app.api_provider, model, app.accepts_custom_model_ids()),
);
}
if include_current_model && !app.auto_model {
push_model_id(
&mut models,
picker_visible_model_id(
app.api_provider,
app.model.trim(),
app.accepts_custom_model_ids(),
),
);
}
models
}
fn push_model_id(models: &mut Vec<String>, model: &str) {
let model = model.trim();
if model.is_empty() {
return;
}
if !models
.iter()
.any(|existing| existing.eq_ignore_ascii_case(model))
{
models.push(model.to_string());
}
}
fn picker_visible_model_id(
provider: ApiProvider,
model: &str,
preserve_endpoint_model_ids: bool,
) -> &str {
if !preserve_endpoint_model_ids
&& matches!(
provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
)
&& (model.eq_ignore_ascii_case("deepseek-chat")
|| model.eq_ignore_ascii_case("deepseek-reasoner"))
{
DEEPSEEK_ALIAS_REPLACEMENT
} else {
model
}
}
fn provider_query_splits(query: &str) -> Vec<(&str, &str)> {
let trimmed = query.trim();
let mut splits = Vec::new();
if let Some((provider, model)) = trimmed.split_once(':') {
splits.push((provider.trim(), model.trim()));
}
if let Some(idx) = trimmed.find(char::is_whitespace) {
let (provider, model) = trimmed.split_at(idx);
splits.push((provider.trim(), model.trim()));
}
splits
}
fn push_model_row(
rows: &mut Vec<ModelPickerRow>,
id: String,
provider: Option<ApiProvider>,
hint: String,
metadata: EffectivePickerMetadata,
selectable: bool,
blocked_reason: Option<String>,
) {
if rows
.iter()
.any(|row| row.id == id && row.provider == provider)
{
return;
}
rows.push(ModelPickerRow {
id,
provider,
provider_identity: None,
hint,
metadata,
selectable,
blocked_reason,
enabled: false,
});
}
fn catalog_freshness_title_suffix() -> &'static str {
match models_dev_live::status().freshness {
ModelsDevFreshness::Stale => " · stale",
ModelsDevFreshness::Failed => " · cache failed",
ModelsDevFreshness::Bundled | ModelsDevFreshness::Live => "",
}
}
fn model_row_matches_query(
row: &ModelPickerRow,
query: &str,
initial_provider: ApiProvider,
) -> bool {
let query = query.trim().to_ascii_lowercase();
if query.is_empty() {
return true;
}
let normalized_query = normalize_picker_search_text(&query);
let matches = |candidate: &str| {
let candidate = candidate.to_ascii_lowercase();
candidate.contains(&query)
|| normalize_picker_search_text(&candidate).contains(&normalized_query)
};
let provider_matches = row.provider.is_some_and(|provider| {
row.provider_identity.as_deref().is_some_and(matches)
|| matches(provider.as_str())
|| matches(provider.display_name())
});
provider_matches
|| matches(&row.id)
|| ((row.provider.is_none() || row.provider == Some(initial_provider))
&& matches(&row.hint))
}
fn normalize_picker_search_text(text: &str) -> String {
text.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn route_labels_for_rows(rows: &[&ModelPickerRow]) -> BTreeMap<&'static str, String> {
let mut by_display: BTreeMap<&'static str, Vec<ApiProvider>> = BTreeMap::new();
for provider in rows.iter().filter_map(|row| row.provider) {
let bucket = by_display.entry(provider.display_name()).or_default();
if !bucket.contains(&provider) {
bucket.push(provider);
}
}
let mut labels = BTreeMap::new();
for (display, providers) in by_display {
let ambiguous = providers.len() > 1;
for provider in providers {
let label = match ambiguous.then(|| route_discriminator(display, provider.as_str())) {
Some(Some(suffix)) => format!("{display} {suffix}"),
Some(None) | None => display.to_string(),
};
labels.insert(provider.as_str(), label);
}
}
labels
}
fn route_discriminator(display: &str, provider_id: &str) -> Option<String> {
let squash = |text: &str| -> String {
text.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
};
let display_key = squash(display).to_ascii_lowercase();
let id_key = squash(provider_id).to_ascii_lowercase();
if display_key.is_empty() || !id_key.starts_with(&display_key) {
return None;
}
let display_key_chars = display_key.chars().count();
let mut consumed = 0usize;
let mut tail = provider_id;
for (offset, ch) in provider_id.char_indices() {
if consumed == display_key_chars {
tail = &provider_id[offset..];
break;
}
if ch.is_alphanumeric() {
consumed += 1;
}
tail = &provider_id[offset + ch.len_utf8()..];
}
let tail = tail.trim_matches(|c: char| !c.is_alphanumeric());
(!tail.is_empty()).then(|| tail.to_string())
}
fn catalog_family_for(provider: ApiProvider, model_id: &str) -> Option<String> {
crate::provider_lake::catalog_offering_for_model(provider, model_id)
.and_then(|offering| offering.family)
}
fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
let mut chips = Vec::new();
if let Some(context_window) = row.metadata.context_window {
chips.push(format_picker_context_window(u64::from(context_window)));
}
chips.push(
if row.metadata.reasoning {
"reasoning"
} else {
"no reasoning"
}
.to_string(),
);
if let Some(max_output) = row.metadata.max_output {
chips.push(format!("{max_output} out"));
}
match row.metadata.vision {
SupportState::Supported => chips.push("vision".to_string()),
SupportState::Unsupported => chips.push("text only".to_string()),
SupportState::Unknown => {}
}
if let Some(tool_calls) = row.metadata.tool_calls {
chips.push(if tool_calls {
"tools".to_string()
} else {
"no tools".to_string()
});
}
if let Some(reason) = row.blocked_reason.as_deref() {
chips.push(reason.to_string());
}
chips
}
fn fit_meta_chips(chips: &[String], width: usize) -> String {
for take in (1..=chips.len()).rev() {
let joined = chips[..take].join(" · ");
if unicode_width::UnicodeWidthStr::width(joined.as_str()) <= width {
return joined;
}
}
match chips.first() {
Some(first) if width >= MIN_META_WIDTH => fit_text(first, width),
_ => String::new(),
}
}
fn model_row_visible_in_view(row: &ModelPickerRow, view: ModelListView) -> bool {
match view {
ModelListView::Configured => model_row_visible_by_default(row),
ModelListView::Catalog => true,
ModelListView::Recent
| ModelListView::Coding
| ModelListView::Cheap
| ModelListView::LongContext => {
row.provider.is_some() || row.id != "auto"
}
}
}
fn model_row_visible_by_default(row: &ModelPickerRow) -> bool {
row.provider.is_none() || row.enabled
}
fn sort_model_rows_for_view(
rows: &mut [&ModelPickerRow],
view: ModelListView,
pins: &[PinnedModel],
) {
let pin_rank = |row: &ModelPickerRow| {
row_provider_identity(row)
.and_then(|provider| {
pins.iter().position(|pin| {
provider.eq_ignore_ascii_case(&pin.provider)
&& row.id.eq_ignore_ascii_case(&pin.model)
})
})
.unwrap_or(usize::MAX)
};
match view {
ModelListView::Configured | ModelListView::Catalog => rows.sort_by_key(|row| pin_rank(row)),
ModelListView::Recent => rows.sort_by(|left, right| {
offering_fetched_at(right)
.cmp(&offering_fetched_at(left))
.then_with(|| left.id.cmp(&right.id))
}),
ModelListView::Coding => rows.sort_by(|left, right| {
coding_score(right)
.cmp(&coding_score(left))
.then_with(|| left.id.cmp(&right.id))
}),
ModelListView::Cheap => rows.sort_by(|left, right| {
match (
input_price_per_million(left),
input_price_per_million(right),
) {
(Some(l), Some(r)) => l
.partial_cmp(&r)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| left.id.cmp(&right.id)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => left.id.cmp(&right.id),
}
}),
ModelListView::LongContext => rows.sort_by(|left, right| {
context_tokens(right)
.cmp(&context_tokens(left))
.then_with(|| left.id.cmp(&right.id))
}),
}
}
fn row_provider_identity(row: &ModelPickerRow) -> Option<&str> {
row.provider_identity.as_deref().or_else(|| {
row.provider
.filter(|provider| *provider != ApiProvider::Custom)
.map(ApiProvider::as_str)
})
}
fn offering_for_row(row: &ModelPickerRow) -> Option<codewhale_config::catalog::CatalogOffering> {
let provider = row.provider?;
catalog_offering_for_model(provider, &row.id)
}
fn offering_fetched_at(row: &ModelPickerRow) -> u64 {
match offering_for_row(row).map(|o| o.source) {
Some(CatalogSource::Live { fetched_at, .. }) => fetched_at,
_ => 0,
}
}
fn context_tokens(row: &ModelPickerRow) -> u64 {
row.metadata.context_window.map(u64::from).unwrap_or(0)
}
fn input_price_per_million(row: &ModelPickerRow) -> Option<f64> {
if matches!(row.metadata.pricing, PickerPricing::Unavailable) {
return None;
}
offering_for_row(row)
.and_then(|offering| OfferingPricing::from_catalog_offering(&offering))
.and_then(|pricing| pricing.input_per_million)
}
fn coding_score(row: &ModelPickerRow) -> u32 {
let mut score = 0_u32;
if let Some(offering) = offering_for_row(row) {
let text_ok = offering.modalities.as_ref().is_none_or(|modalities| {
modalities.output.is_empty()
|| modalities
.output
.iter()
.any(|m| m.eq_ignore_ascii_case("text"))
});
if text_ok {
score += 40;
}
}
if row.metadata.tool_calls == Some(true) {
score += 40;
}
if row.metadata.reasoning {
score += 10;
}
if row.metadata.context_window.unwrap_or(0) >= 100_000 {
score += 10;
}
score
}
#[cfg(test)]
fn picker_model_hint(id: &str, provider: Option<ApiProvider>) -> String {
let config = Config::default();
let metadata = effective_picker_metadata(&config, provider, id);
let codex_freshness = (provider == Some(ApiProvider::OpenaiCodex))
.then(|| codex_model_cache::model_roster().freshness);
render_picker_model_hint(id, provider, &metadata, codex_freshness)
}
fn effective_picker_metadata(
config: &Config,
provider: Option<ApiProvider>,
id: &str,
) -> EffectivePickerMetadata {
effective_picker_metadata_with_codex(config, provider, id, None)
}
fn effective_picker_metadata_with_codex(
config: &Config,
provider: Option<ApiProvider>,
id: &str,
codex_metadata: Option<&CodexModelMetadata>,
) -> EffectivePickerMetadata {
let offering = provider.and_then(|provider| catalog_offering_for_model(provider, id));
let card = offering.as_ref().map(ModelReferenceCard::from_offering);
let registry = model_registry::lookup(id);
let Some(provider) = provider else {
return EffectivePickerMetadata {
context_window: registry.as_ref().and_then(|meta| meta.context_window),
max_output: registry.as_ref().and_then(|meta| meta.max_output),
tool_calls: None,
reasoning: registry
.as_ref()
.is_some_and(|meta| meta.supports_reasoning),
vision: SupportState::Unknown,
pricing: if crate::pricing::has_pricing_for_model(id) {
PickerPricing::Known("priced".to_string())
} else {
PickerPricing::Unknown
},
source: None,
};
};
let context_override = config.context_window_for_provider_config(provider);
let overrides = CapabilityOverride {
context_window: context_override,
..CapabilityOverride::default()
};
let profile = offering.as_ref().map_or_else(
|| resolved_capability_profile_with_overrides(provider, id, overrides.clone()),
|offering| {
let route_offering = offering.to_offering();
resolved_capability_profile_for_route_with_overrides(
provider,
id,
route_offering.capabilities,
route_offering.limits,
overrides.clone(),
)
},
);
let card_context = card
.as_ref()
.and_then(|card| card.context_window)
.map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
let preserves_unknown_limits = offering.is_some()
|| (provider == ApiProvider::Together
&& id.eq_ignore_ascii_case(crate::config::TOGETHER_INKLING_MODEL));
let context_window = if context_override.is_some() {
profile.context_window
} else if provider == ApiProvider::OpenaiCodex {
codex_metadata.and_then(|metadata| metadata.context_window)
} else if preserves_unknown_limits {
card_context
} else {
profile.context_window
};
let card_output = card
.as_ref()
.and_then(|card| card.max_output)
.map(|tokens| tokens.min(u64::from(u32::MAX)) as u32);
let max_output = if provider == ApiProvider::OpenaiCodex {
None
} else if preserves_unknown_limits {
card_output
} else {
profile.max_output
};
let profile_tool_calls = match profile.native_tool_calls {
SupportState::Supported => Some(true),
SupportState::Unsupported => Some(false),
SupportState::Unknown => None,
};
let tool_calls = if provider == ApiProvider::OpenaiCodex {
codex_metadata.and(profile_tool_calls)
} else {
offering
.as_ref()
.and_then(|offering| offering.tool_call)
.or(profile_tool_calls)
};
let reasoning = if provider == ApiProvider::OpenaiCodex {
codex_metadata
.map(|metadata| {
metadata
.reasoning
.unwrap_or_else(|| profile.supports_reasoning())
})
.unwrap_or(false)
} else {
offering
.as_ref()
.and_then(|offering| offering.reasoning)
.unwrap_or_else(|| profile.supports_reasoning())
};
let vision = profile.image_input;
let card_price = card.as_ref().and_then(|card| {
let label = card.price_label();
(label != "unknown").then_some(label)
});
let pricing = if provider == ApiProvider::OpenaiCodex {
PickerPricing::Unavailable
} else if let Some(label) = card_price {
PickerPricing::Known(label)
} else if crate::pricing::has_pricing_for_provider(provider, id) {
PickerPricing::Known("priced".to_string())
} else {
PickerPricing::Unknown
};
EffectivePickerMetadata {
context_window,
max_output,
tool_calls,
reasoning,
vision,
pricing,
source: card.map(|card| card.source),
}
}
fn render_picker_model_hint(
id: &str,
provider: Option<ApiProvider>,
metadata: &EffectivePickerMetadata,
codex_freshness: Option<CodexModelCacheFreshness>,
) -> String {
debug_assert_ne!(id, "auto", "Auto rows use the context-aware picker hint");
let mut parts = Vec::new();
if provider == Some(ApiProvider::Moonshot) {
match id.trim().to_ascii_lowercase().as_str() {
"k3" => parts.push("Kimi Code plan route".to_string()),
"kimi-k3" | "moonshotai/kimi-k3" => parts.push("Moonshot direct route".to_string()),
_ => {}
}
}
if let Some(context_window) = metadata.context_window {
if provider == Some(ApiProvider::OpenaiCodex) {
parts.push(format!(
"{} ctx · ChatGPT route",
format_picker_context_window(u64::from(context_window))
));
} else if provider == Some(ApiProvider::Moonshot)
&& id.trim().eq_ignore_ascii_case("k3")
&& context_window == crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS
{
parts.push(format!(
"{} ctx (plan floor; raise via context_window)",
format_picker_context_window(u64::from(context_window))
));
} else {
parts.push(format!(
"{} ctx",
format_picker_context_window(u64::from(context_window))
));
}
}
if let Some(max_output) = metadata.max_output {
parts.push(format!(
"{} out",
format_picker_context_window(u64::from(max_output))
));
}
match metadata.tool_calls {
Some(true) => parts.push("tools".to_string()),
Some(false) => parts.push("no tools".to_string()),
None => {}
}
if metadata.reasoning {
parts.push("reasoning".to_string());
}
match metadata.vision {
SupportState::Supported => parts.push("vision".to_string()),
SupportState::Unsupported => parts.push("no vision".to_string()),
SupportState::Unknown => {}
}
match &metadata.pricing {
PickerPricing::Unavailable => {}
PickerPricing::Known(label) => parts.push(label.clone()),
PickerPricing::Unknown => parts.push("price unknown".to_string()),
}
match metadata.source.as_ref() {
Some(CatalogSource::Live { .. }) => parts.push("live".to_string()),
Some(CatalogSource::Bundled) => parts.push("bundled".to_string()),
Some(CatalogSource::UserOverride) => parts.push("override".to_string()),
None => {}
}
if provider == Some(ApiProvider::OpenaiCodex) {
parts.push(match codex_freshness {
Some(freshness) => freshness.picker_label().to_string(),
None => "custom · OAuth roster unconfirmed".to_string(),
});
}
if parts.is_empty() {
"provider model".to_string()
} else {
parts.join(" · ")
}
}
pub(crate) fn format_picker_context_window(tokens: u64) -> String {
if tokens >= 1_000_000 {
if tokens.is_multiple_of(1_000_000) {
format!("{}M", tokens / 1_000_000)
} else {
format!("{:.2}M", tokens as f64 / 1_000_000.0)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
} else if tokens >= 1_000 {
format!("{}K", tokens / 1_000)
} else {
tokens.to_string()
}
}
impl ModelPickerView {
pub fn re_resolve_from_app(&mut self, app: &App, config: &Config) {
let selected = self
.visible_model_rows()
.get(self.selected_model_idx)
.map(|row| {
(
row_provider_identity(row).map(str::to_string),
row.id.clone(),
)
});
self.provider_health = app.provider_health.clone();
self.route_config = config.clone();
self.pinned_models = app.pinned_models.clone();
self.model_rows = picker_model_rows_for_app(app, config);
self.configured_providers = configured_providers(config, app.api_provider)
.into_iter()
.filter(|provider| *provider != app.api_provider)
.collect();
if let Some((provider, model)) = selected
&& let Some(position) = self.visible_model_rows().iter().position(|row| {
row.id.eq_ignore_ascii_case(&model)
&& row_provider_identity(row).map(str::to_owned) == provider
})
{
self.selected_model_idx = position;
return;
}
let rows = self.visible_model_rows();
if self.selected_model_idx >= rows.len() + usize::from(self.show_custom_model_row) {
self.selected_model_idx = rows.len().saturating_sub(1);
}
}
}
impl ModelPickerView {
fn emit_pin_move(&self, delta: isize) -> ViewAction {
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let Some(provider) = row.provider else {
return ViewAction::None;
};
ViewAction::Emit(ViewEvent::ModelPickerMovePin {
provider,
provider_id: row.provider_identity.clone(),
model: row.id.clone(),
delta,
})
}
}
impl ModalView for ModelPickerView {
fn kind(&self) -> ModalKind {
ModalKind::ModelPicker
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
catalog_view: self.view.browses_all_providers(),
view: self.view.memory_name().to_string(),
selected_row_id: {
let rows = self.visible_model_rows();
rows.get(self.selected_model_idx).map(|row| row.id.clone())
},
}),
KeyCode::Enter if self.model_row_count() == 0 => ViewAction::None,
KeyCode::Enter if !self.selected_model_is_selectable() => {
self.explain_unselectable_selection()
}
KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
KeyCode::Char(ch)
if key.modifiers.contains(KeyModifiers::SHIFT)
&& self.query.is_empty()
&& ch.eq_ignore_ascii_case(&'d')
&& self.selected_model_is_selectable() =>
{
ViewAction::EmitAndClose(self.build_event_with_startup_default(true))
}
KeyCode::Char(ch)
if key.modifiers.contains(KeyModifiers::SHIFT) && ch.eq_ignore_ascii_case(&'d') =>
{
self.explain_unselectable_selection()
}
KeyCode::Char('p') if key.modifiers.is_empty() && self.query.is_empty() => {
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let Some(provider) = row.provider else {
return ViewAction::None;
};
ViewAction::Emit(ViewEvent::ModelPickerTogglePin {
provider,
provider_id: row.provider_identity.clone(),
model: row.id.clone(),
})
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
self.emit_pin_move(-1)
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
self.emit_pin_move(1)
}
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'a') =>
{
self.toggle_view();
ViewAction::None
}
KeyCode::Char(ch)
if self.focus == Pane::Model
&& !key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
let mut query = self.query.clone();
query.push(ch);
self.update_query(query);
ViewAction::None
}
KeyCode::Backspace if self.focus == Pane::Model && !self.query.is_empty() => {
let mut query = self.query.clone();
query.pop();
self.update_query(query);
ViewAction::None
}
KeyCode::Up => {
self.move_up();
ViewAction::None
}
KeyCode::Down => {
self.move_down();
ViewAction::None
}
KeyCode::PageUp => {
for _ in 0..5 {
self.move_up();
}
ViewAction::None
}
KeyCode::PageDown => {
for _ in 0..5 {
self.move_down();
}
ViewAction::None
}
KeyCode::Home => {
match self.focus {
Pane::Model => {
self.selected_model_idx = 0;
self.select_effort_for_current_model();
}
Pane::Effort => {
self.selected_effort_idx = 0;
self.selected_effort_request = self.resolved_effort();
}
}
ViewAction::None
}
KeyCode::End => {
match self.focus {
Pane::Model => {
self.selected_model_idx = self.model_row_count().saturating_sub(1);
self.select_effort_for_current_model();
}
Pane::Effort => {
self.selected_effort_idx = self.current_efforts().len().saturating_sub(1);
self.selected_effort_request = self.resolved_effort();
}
}
ViewAction::None
}
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
self.toggle_focus();
ViewAction::None
}
KeyCode::Char('r') | KeyCode::Char('R')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
|| (key.modifiers.is_empty() && self.query.is_empty()) =>
{
ViewAction::Emit(ViewEvent::ModelPickerRefresh)
}
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
match mouse.kind {
MouseEventKind::ScrollUp => {
self.last_mouse_selected = None;
self.move_up();
ViewAction::None
}
MouseEventKind::ScrollDown => {
self.last_mouse_selected = None;
self.move_down();
ViewAction::None
}
MouseEventKind::Down(MouseButton::Left) => {
let clicked = self
.row_hitboxes
.borrow()
.iter()
.find_map(|(rect, pane, idx)| {
rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
.then_some((*pane, *idx))
});
let Some((pane, idx)) = clicked else {
return ViewAction::None;
};
let apply = self.last_mouse_selected == Some((pane, idx))
&& self.focus == pane
&& match pane {
Pane::Model => self.selected_model_idx == idx,
Pane::Effort => self.selected_effort_idx == idx,
};
self.focus = pane;
match pane {
Pane::Model => {
self.selected_model_idx = idx.min(self.model_row_count().saturating_sub(1));
self.select_effort_for_current_model();
}
Pane::Effort => {
self.selected_effort_idx =
idx.min(self.current_efforts().len().saturating_sub(1));
self.selected_effort_request = self.resolved_effort();
}
}
self.last_mouse_selected = Some((pane, idx));
if apply && self.selected_model_is_selectable() {
ViewAction::EmitAndClose(self.build_event())
} else if apply {
self.explain_unselectable_selection()
} else {
ViewAction::None
}
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_route(area, buf);
}
}
impl ModelPickerView {
fn render_route(&self, area: Rect, buf: &mut Buffer) {
self.row_hitboxes.borrow_mut().clear();
let inner = render_underwater_surface(
area,
buf,
tr(self.locale, MessageId::RouteSurfaceTitle)
.replace("{view}", self.view.title_label()),
);
let view_action: std::borrow::Cow<'static, str> = match self.view {
ModelListView::Configured => tr(self.locale, MessageId::RouteBrowseCatalog),
other => other.next().title_label().into(),
};
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new("↑↓", tr(self.locale, MessageId::PickerActionMove)),
ActionHint::new("Tab", tr(self.locale, MessageId::PickerActionSwitch)),
ActionHint::new(
tr(self.locale, MessageId::RouteActionType),
tr(self.locale, MessageId::RouteActionSearchAnyModel),
),
ActionHint::new("Enter", tr(self.locale, MessageId::PickerActionApply)),
ActionHint::new(
"⇧D",
tr(self.locale, MessageId::PickerActionSetStartupDefault),
),
ActionHint::new("A", view_action),
ActionHint::new("Esc", tr(self.locale, MessageId::PickerActionCancel)),
],
);
let shell = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
ratatui::layout::Constraint::Length(3),
ratatui::layout::Constraint::Min(1),
])
.split(content);
Paragraph::new(vec![
Line::from(vec![
Span::styled(
format!("─ {} ", tr(self.locale, MessageId::RoutePanelHeader)),
Style::default().fg(palette::WHALE_ACTION).bold(),
),
Span::styled(
"──────────────────────── ",
Style::default().fg(palette::BORDER_COLOR),
),
Span::styled(
format!(
"{}{}",
self.view.title_label(),
catalog_freshness_title_suffix()
),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
" ─────────────────",
Style::default().fg(palette::BORDER_COLOR),
),
]),
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {} ", tr(self.locale, MessageId::RouteProviderLabel)),
Style::default().fg(palette::WHALE_INFO),
),
Span::styled(
self.resolved_provider()
.unwrap_or(self.initial_provider)
.display_name(),
Style::default().fg(palette::TEXT_PRIMARY),
),
Span::styled(
format!(" · {}", tr(self.locale, MessageId::RouteModelFirstAtomic)),
Style::default().fg(palette::TEXT_MUTED),
),
]),
])
.render(shell[0], buf);
let layout = widen_model_pane(ListDetailLayout::split(shell[1], 24));
let visible = self.visible_model_rows();
let route_labels = route_labels_for_rows(&visible);
let mut model_rows: Vec<PaneRow> = visible
.iter()
.map(|row| {
let active = row.id == self.initial_model
&& (row.provider.is_none() || row.provider == Some(self.initial_provider));
match row.provider {
None => PaneRow {
primary: row.id.clone(),
route: String::new(),
meta: vec![row.hint.clone()],
family: None,
active,
},
Some(provider) => PaneRow {
primary: row.id.clone(),
route: route_labels
.get(provider.as_str())
.cloned()
.unwrap_or_else(|| provider.display_name().to_string()),
meta: model_row_meta_chips(row),
family: catalog_family_for(provider, &row.id),
active,
},
}
})
.collect();
if let Some((model, provider)) = self.custom_model_row() {
model_rows.push(PaneRow {
primary: model,
family: None,
route: provider.display_name().to_string(),
meta: vec![if self.query.trim().is_empty() {
"current (custom)".to_string()
} else {
"custom route".to_string()
}],
active: false,
});
}
let model_title = if self.query.trim().is_empty() {
format!("Model · {}", self.view.title_label())
} else {
format!("Model: {}", self.query.trim())
};
self.render_pane(
layout.list,
buf,
&model_title,
model_rows,
PaneRenderState {
pane: Pane::Model,
selected: self.selected_model_idx,
focused: self.focus == Pane::Model,
},
);
let effort_provider = self.resolved_provider().unwrap_or(self.initial_provider);
let current_efforts = self.current_efforts();
let selected_effort_idx = self
.selected_effort_idx
.min(current_efforts.len().saturating_sub(1));
let effort_rows: Vec<PaneRow> = current_efforts
.iter()
.map(|effort| {
let label = effort
.display_label_for_provider(effort_provider)
.to_string();
let hint = match effort {
ReasoningEffort::Auto => "choose per turn".to_string(),
ReasoningEffort::Off => "no extra reasoning".to_string(),
ReasoningEffort::Minimal => "minimal reasoning".to_string(),
ReasoningEffort::Low => "lighter reasoning".to_string(),
ReasoningEffort::Medium => "balanced reasoning".to_string(),
ReasoningEffort::High => "deeper reasoning".to_string(),
ReasoningEffort::XHigh => "extra-high reasoning".to_string(),
ReasoningEffort::Ultra => "ultra reasoning".to_string(),
ReasoningEffort::Max => {
if effort_provider == ApiProvider::OpenaiCodex {
"extra-high reasoning".to_string()
} else {
"maximum reasoning".to_string()
}
}
};
PaneRow::effort(label, hint)
})
.collect();
self.render_pane(
layout.detail,
buf,
"Thinking",
effort_rows,
PaneRenderState {
pane: Pane::Effort,
selected: selected_effort_idx,
focused: self.focus == Pane::Effort,
},
);
}
}
pub(crate) fn picker_efforts_for_route(
provider: ApiProvider,
base_url: &str,
wire_model: &str,
model_is_auto: bool,
) -> Vec<ReasoningEffort> {
if model_is_auto {
return AUTO_MODEL_PICKER_EFFORTS.to_vec();
}
if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
return KIMI_CODE_K3_PICKER_EFFORTS.to_vec();
}
if provider == ApiProvider::OpenaiCodex {
return CODEX_PICKER_EFFORTS.to_vec();
}
if let Some(catalog_efforts) = catalog_picker_efforts(provider, wire_model) {
return catalog_efforts;
}
if matches!(
provider,
crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
) {
return DEEPSEEK_PICKER_EFFORTS.to_vec();
}
DEFAULT_PICKER_EFFORTS.to_vec()
}
fn catalog_picker_efforts(provider: ApiProvider, wire_model: &str) -> Option<Vec<ReasoningEffort>> {
let offering = catalog_offering_for_model(provider, wire_model)?;
let mut efforts = Vec::new();
let mut saw_effort_list = false;
for option in &offering.reasoning_options {
let option_type = option
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_ascii_lowercase();
if option_type != "effort" && option_type != "thinking" {
continue;
}
let Some(values) = option.get("values").and_then(|value| value.as_array()) else {
continue;
};
saw_effort_list = true;
for value in values {
let Some(raw) = value.as_str() else {
continue;
};
if let Some(effort) = catalog_effort_value(raw)
&& !efforts.contains(&effort)
{
efforts.push(effort);
}
}
}
if !saw_effort_list || efforts.is_empty() {
return None;
}
if !efforts.contains(&ReasoningEffort::Auto) {
efforts.insert(0, ReasoningEffort::Auto);
}
Some(efforts)
}
fn catalog_effort_value(raw: &str) -> Option<ReasoningEffort> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "false" => Some(ReasoningEffort::Off),
"none" => Some(ReasoningEffort::Off), "minimal" | "minimum" => Some(ReasoningEffort::Minimal),
"low" | "light" => Some(ReasoningEffort::Low),
"medium" | "mid" => Some(ReasoningEffort::Medium),
"high" => Some(ReasoningEffort::High),
"xhigh" => Some(ReasoningEffort::XHigh),
"ultra" | "ultracode" => Some(ReasoningEffort::Ultra),
"max" | "maximum" => Some(ReasoningEffort::Max),
"auto" | "automatic" | "adaptive" => Some(ReasoningEffort::Auto),
"always_on" | "always-on" => Some(ReasoningEffort::Max),
_ => None,
}
}
fn normalize_picker_effort(
effort: ReasoningEffort,
provider: ApiProvider,
base_url: &str,
wire_model: &str,
model_is_auto: bool,
) -> ReasoningEffort {
let normalized = if model_is_auto {
effort
} else {
effort.normalize_for_route(provider, base_url, wire_model)
};
let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
if efforts.contains(&normalized) {
return normalized;
}
if efforts.contains(&effort) {
return effort;
}
default_picker_effort(provider, &efforts)
}
fn default_picker_effort(provider: ApiProvider, efforts: &[ReasoningEffort]) -> ReasoningEffort {
let preferred = if provider == ApiProvider::OpenaiCodex {
ReasoningEffort::Medium
} else {
ReasoningEffort::High
};
if efforts.contains(&preferred) {
preferred
} else {
efforts
.iter()
.copied()
.find(|effort| *effort != ReasoningEffort::Auto && *effort != ReasoningEffort::Off)
.or_else(|| efforts.first().copied())
.unwrap_or(preferred)
}
}
fn default_picker_effort_idx(
provider: ApiProvider,
base_url: &str,
wire_model: &str,
model_is_auto: bool,
) -> usize {
let efforts = picker_efforts_for_route(provider, base_url, wire_model, model_is_auto);
let default_effort = default_picker_effort(provider, &efforts);
efforts
.iter()
.position(|effort| *effort == default_effort)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::{App, TuiOptions};
use std::path::PathBuf;
fn create_test_app() -> (
App,
Config,
(
Vec<crate::test_support::EnvVarGuard>,
crate::test_support::TestEnvLock,
),
) {
let lock = crate::test_support::lock_test_env();
let mut env_guards = Vec::new();
let mut seen = std::collections::HashSet::new();
for provider in ApiProvider::sorted_for_display() {
for &name in provider.env_vars() {
if seen.insert(name) {
env_guards.push(crate::test_support::EnvVarGuard::remove(name));
}
}
}
env_guards.push(crate::test_support::EnvVarGuard::set(
"OPENAI_CODEX_AUTH_FILE",
"/nonexistent/codewhale-test-codex-auth.json",
));
env_guards.push(crate::test_support::EnvVarGuard::set(
"CODEX_HOME",
"/nonexistent/codewhale-test-codex-home",
));
env_guards.push(crate::test_support::EnvVarGuard::set(
"GROK_AUTH_PATH",
"/nonexistent/codewhale-test-grok-auth.json",
));
let options = TuiOptions {
start_in_agent_mode: true,
..crate::test_support::test_tui_options(PathBuf::from("."))
};
let config = Config::default();
let mut app = App::new(options, &config);
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
app.reasoning_effort_preference = None;
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app.provider_models.clear();
app.enabled_provider_models.clear();
app.pinned_models.clear();
app.model_picker_memory = None;
(app, config, (env_guards, lock))
}
fn type_model_query(view: &mut ModelPickerView, query: &str) {
for ch in query.chars() {
view.handle_key(KeyEvent::new(
KeyCode::Char(ch),
crossterm::event::KeyModifiers::NONE,
));
}
}
fn buffer_row_text(buf: &Buffer, area: Rect, y: u16) -> String {
(area.x..area.x.saturating_add(area.width))
.map(|x| buf[(x, y)].symbol())
.collect()
}
fn row_containing(buf: &Buffer, area: Rect, needle: &str) -> Option<u16> {
(area.y..area.y.saturating_add(area.height))
.find(|&y| buffer_row_text(buf, area, y).contains(needle))
}
fn rendered_model_rows(view: &ModelPickerView, width: u16, height: u16) -> Vec<String> {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let mut rows = Vec::new();
for (rect, pane, _) in view.row_hitboxes.borrow().iter() {
if *pane == Pane::Model {
rows.push(buffer_row_text(&buf, *rect, rect.y).trim_end().to_string());
}
}
rows
}
#[test]
fn deepseek_rows_render_distinguishably() {
let (app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-picker-test-key".to_string());
let mut view = ModelPickerView::new(&app, &config);
view.view = ModelListView::Catalog;
type_model_query(&mut view, "deepseek");
for (width, height) in [(120_u16, 40_u16), (100, 30), (80, 24)] {
let rows = rendered_model_rows(&view, width, height);
assert!(
rows.len() > 2,
"{width}x{height}: expected a populated DeepSeek list, got {rows:?}"
);
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for row in &rows {
*seen.entry(row.as_str()).or_default() += 1;
}
let collisions: Vec<_> = seen
.iter()
.filter(|(_, count)| **count > 1)
.map(|(row, count)| format!("{count}x {row:?}"))
.collect();
assert!(
collisions.is_empty(),
"{width}x{height}: rows must be distinguishable, found duplicates: {collisions:?}"
);
for row in rows
.iter()
.filter(|row| row.to_lowercase().contains("deepseek"))
{
assert!(
row.contains("reasoning"),
"{width}x{height}: row lost its reasoning stance: {row:?}"
);
assert!(
row.contains('M') || row.contains('K'),
"{width}x{height}: row lost its context window: {row:?}"
);
}
}
}
#[test]
fn same_named_deepseek_providers_get_distinct_route_labels() {
let rows = [
ModelPickerRow {
id: "deepseek-v4-pro".to_string(),
provider: Some(ApiProvider::Deepseek),
provider_identity: None,
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
},
ModelPickerRow {
id: "deepseek-v4-pro".to_string(),
provider: Some(ApiProvider::DeepseekAnthropic),
provider_identity: None,
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
},
];
assert_eq!(
ApiProvider::Deepseek.display_name(),
ApiProvider::DeepseekAnthropic.display_name(),
"this test is only meaningful while the display names actually collide"
);
let borrowed: Vec<&ModelPickerRow> = rows.iter().collect();
let labels = route_labels_for_rows(&borrowed);
let direct = labels.get("deepseek").expect("direct route label");
let anthropic = labels
.get("deepseek-anthropic")
.expect("anthropic-dialect route label");
assert_ne!(
direct, anthropic,
"colliding display names must be disambiguated"
);
assert_eq!(anthropic, "DeepSeek anthropic");
}
#[test]
fn unique_route_labels_stay_bare_display_names() {
let row = ModelPickerRow {
id: "deepseek-v4-pro".to_string(),
provider: Some(ApiProvider::Deepseek),
provider_identity: None,
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
};
let borrowed = vec![&row];
let labels = route_labels_for_rows(&borrowed);
assert_eq!(labels.get("deepseek").map(String::as_str), Some("DeepSeek"));
}
#[test]
fn meta_chips_shed_whole_units_under_pressure() {
let chips = vec![
"1M".to_string(),
"reasoning".to_string(),
"missing key".to_string(),
];
assert_eq!(fit_meta_chips(&chips, 40), "1M · reasoning · missing key");
assert_eq!(fit_meta_chips(&chips, 20), "1M · reasoning");
assert_eq!(fit_meta_chips(&chips, 5), "1M");
assert_eq!(fit_meta_chips(&chips, 1), "");
}
#[test]
fn unknown_context_window_is_omitted_not_guessed() {
let row = ModelPickerRow {
id: "some-unlisted-model".to_string(),
provider: Some(ApiProvider::Deepseek),
provider_identity: None,
hint: String::new(),
metadata: EffectivePickerMetadata {
context_window: None,
reasoning: true,
..EffectivePickerMetadata::default()
},
selectable: true,
blocked_reason: None,
enabled: true,
};
assert_eq!(model_row_meta_chips(&row), vec!["reasoning".to_string()]);
}
#[test]
fn model_picker_hint_uses_model_registry_metadata() {
let hint = picker_model_hint("minimax/minimax-m3", None);
assert!(
hint.contains("1M ctx"),
"hint should include registry context window: {hint}"
);
assert!(
hint.contains("reasoning"),
"hint should include registry reasoning support: {hint}"
);
assert!(
hint.contains("price unknown"),
"hint should surface honest pricing availability: {hint}"
);
let priced_hint = picker_model_hint("minimax/minimax-m2.7", None);
assert!(
priced_hint.contains("priced")
|| priced_hint.contains("per Mtok")
|| priced_hint.contains("$"),
"hint should include pricing for a priced row: {priced_hint}"
);
}
#[test]
fn auto_picker_hint_discloses_scope_data_path_and_last_route() {
let (mut app, config, _lock) = create_test_app();
app.ui_locale = Locale::En;
app.last_effective_provider = Some(ApiProvider::Zai);
app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string());
let local = auto_picker_hint(&app, &config);
assert!(local.contains("local heuristic"), "{local}");
assert!(local.contains("no router request"), "{local}");
assert!(
local.contains("last Zhipu AI / Z.ai · GLM-5-Turbo"),
"{local}"
);
let _deepseek =
crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "test-router-key");
let still_local = auto_picker_hint(&app, &config);
assert!(still_local.contains("local heuristic"), "{still_local}");
assert!(still_local.contains("no router request"), "{still_local}");
assert!(!still_local.contains("test-router-key"), "{still_local}");
let mut routed = config.clone();
routed.auto = Some(crate::config::AutoConfig {
cost_saving: None,
router: Some(crate::config::AutoRouterConfig {
provider: Some("deepseek".to_string()),
model: Some("deepseek-v4-flash".to_string()),
thinking: None,
}),
cross_provider: None,
});
let network = auto_picker_hint(&app, &routed);
assert!(network.contains("active provider only"), "{network}");
assert!(!network.contains("runnable providers"), "{network}");
let mut widened = routed.clone();
if let Some(auto) = widened.auto.as_mut() {
auto.cross_provider = Some(true);
}
let network = auto_picker_hint(&app, &widened);
assert!(network.contains("runnable providers"), "{network}");
assert!(network.contains("request + recent context"), "{network}");
assert!(
network.contains("DeepSeek / deepseek-v4-flash"),
"{network}"
);
assert!(!network.contains("test-router-key"), "{network}");
let opted_in = Config {
auto: Some(crate::config::AutoConfig {
cost_saving: None,
cross_provider: Some(true),
router: None,
}),
..config.clone()
};
let scope_only = auto_picker_hint(&app, &opted_in);
assert!(scope_only.contains("local heuristic"), "{scope_only}");
assert!(scope_only.contains("no router request"), "{scope_only}");
}
#[test]
fn kimi_k3_rows_name_their_routes_and_plan_floor() {
let config = Config::default();
let membership = effective_picker_metadata(&config, Some(ApiProvider::Moonshot), "k3");
assert_eq!(
membership.context_window,
Some(crate::models::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS)
);
let membership_hint =
render_picker_model_hint("k3", Some(ApiProvider::Moonshot), &membership, None);
assert!(
membership_hint.contains("Kimi Code plan route"),
"{membership_hint}"
);
assert!(
membership_hint.contains("262K ctx (plan floor; raise via context_window)"),
"{membership_hint}"
);
let direct = effective_picker_metadata(&config, Some(ApiProvider::Moonshot), "kimi-k3");
assert_eq!(
direct.context_window,
Some(crate::models::KIMI_K3_CONTEXT_WINDOW_TOKENS)
);
let direct_hint =
render_picker_model_hint("kimi-k3", Some(ApiProvider::Moonshot), &direct, None);
assert!(
direct_hint.contains("Moonshot direct route"),
"{direct_hint}"
);
assert!(direct_hint.contains("1.05M ctx"), "{direct_hint}");
assert!(
!direct_hint.contains("plan floor"),
"the direct route window is not plan-dependent: {direct_hint}"
);
let mut override_config = Config::default();
override_config
.providers
.get_or_insert_with(Default::default)
.moonshot
.context_window = Some(1_048_576);
let upgraded =
effective_picker_metadata(&override_config, Some(ApiProvider::Moonshot), "k3");
assert_eq!(upgraded.context_window, Some(1_048_576));
let upgraded_hint =
render_picker_model_hint("k3", Some(ApiProvider::Moonshot), &upgraded, None);
assert!(upgraded_hint.contains("1.05M ctx"), "{upgraded_hint}");
assert!(
!upgraded_hint.contains("plan floor"),
"configured entitlement must not still read as the floor: {upgraded_hint}"
);
}
#[test]
fn same_model_id_uses_route_effective_api_and_oauth_metadata() {
let config = Config::default();
let api = effective_picker_metadata(&config, Some(ApiProvider::Openai), "gpt-5.5");
let codex_cache = CodexModelMetadata {
id: "gpt-5.5".to_string(),
context_window: Some(272_000),
reasoning: Some(true),
};
let oauth = effective_picker_metadata_with_codex(
&config,
Some(ApiProvider::OpenaiCodex),
"gpt-5.5",
Some(&codex_cache),
);
assert_eq!(api.context_window, Some(1_050_000));
assert_eq!(api.max_output, Some(128_000));
assert!(matches!(api.pricing, PickerPricing::Known(_)));
assert_eq!(oauth.context_window, Some(272_000));
assert_eq!(oauth.max_output, None);
assert_eq!(oauth.pricing, PickerPricing::Unavailable);
assert_eq!(oauth.tool_calls, Some(true));
assert!(oauth.reasoning);
let api_hint = render_picker_model_hint("gpt-5.5", Some(ApiProvider::Openai), &api, None);
let oauth_hint = render_picker_model_hint(
"gpt-5.5",
Some(ApiProvider::OpenaiCodex),
&oauth,
Some(CodexModelCacheFreshness::Fresh),
);
assert!(api_hint.contains("1.05M ctx"), "{api_hint}");
assert!(api_hint.contains("128K out"), "{api_hint}");
assert!(
api_hint.contains("priced") || api_hint.contains('$') || api_hint.contains("per Mtok"),
"{api_hint}"
);
assert!(
oauth_hint.contains("272K ctx · ChatGPT route"),
"OAuth ctx must be labeled route-scoped (TUI-DOG-016): {oauth_hint}"
);
assert!(oauth_hint.contains("tools"), "{oauth_hint}");
assert!(oauth_hint.contains("ChatGPT OAuth"), "{oauth_hint}");
for false_api_fact in ["1.05M", "128K out", "priced", "$", "per Mtok"] {
assert!(
!oauth_hint.contains(false_api_fact),
"OAuth hint inherited API-only fact {false_api_fact:?}: {oauth_hint}"
);
}
}
#[test]
fn provider_context_override_wins_in_picker_metadata() {
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
context_window: Some(123_456),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let metadata = effective_picker_metadata(&config, Some(ApiProvider::Openai), "gpt-5.5");
assert_eq!(metadata.context_window, Some(123_456));
}
#[test]
fn codex_cache_roster_populates_picker_and_preserves_custom_selection() {
let (mut app, config, _lock) = create_test_app();
let codex_home = tempfile::tempdir().expect("temporary CODEX_HOME");
let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
let cache = serde_json::json!({
"fetched_at": chrono::Utc::now(),
"models": [
{"slug": "gpt-fixture-secondary", "priority": 20, "visibility": "list", "context_window": 128000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "medium"}]},
{"slug": "gpt-fixture-primary", "priority": 10, "visibility": "list", "context_window": 372000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "high"}]},
{"slug": "codex-fixture-review", "priority": 30, "visibility": "hide", "context_window": 272000, "supports_parallel_tool_calls": true, "supported_reasoning_levels": [{"effort": "medium"}]}
]
});
std::fs::write(
codex_home.path().join("models_cache.json"),
serde_json::to_vec_pretty(&cache).expect("serialize cache"),
)
.expect("write cache");
app.api_provider = ApiProvider::OpenaiCodex;
app.model = "gpt-private-preview".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let codex_ids: Vec<_> = view
.model_rows
.iter()
.filter(|row| row.provider == Some(ApiProvider::OpenaiCodex))
.map(|row| row.id.as_str())
.collect();
assert_eq!(
codex_ids,
[
"gpt-fixture-primary",
"gpt-fixture-secondary",
"codex-fixture-review"
]
);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "gpt-private-preview");
assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
let primary = view
.model_rows
.iter()
.find(|row| row.id == "gpt-fixture-primary")
.expect("primary row");
let secondary = view
.model_rows
.iter()
.find(|row| row.id == "gpt-fixture-secondary")
.expect("secondary row");
assert!(primary.hint.contains("372K ctx"), "{}", primary.hint);
assert!(secondary.hint.contains("128K ctx"), "{}", secondary.hint);
assert!(
!secondary.hint.contains("no tools"),
"parallel=false must not be misread as no tool support: {}",
secondary.hint
);
}
#[test]
fn saved_codex_model_outside_fresh_roster_is_explicitly_unconfirmed() {
let (mut app, config, _lock) = create_test_app();
let codex_home = tempfile::tempdir().expect("Codex home");
let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
std::fs::write(
codex_home.path().join("models_cache.json"),
serde_json::to_vec(&serde_json::json!({
"fetched_at": chrono::Utc::now(),
"models": [{
"slug": "gpt-roster-confirmed",
"priority": 1,
"context_window": 272000,
"supported_reasoning_levels": [{"effort": "high"}]
}]
}))
.expect("serialize cache"),
)
.expect("write cache");
app.provider_models.insert(
ApiProvider::OpenaiCodex.as_str().to_string(),
"gpt-saved-unconfirmed".to_string(),
);
let view = ModelPickerView::new(&app, &config);
let row = view
.model_rows
.iter()
.find(|row| {
row.provider == Some(ApiProvider::OpenaiCodex) && row.id == "gpt-saved-unconfirmed"
})
.expect("saved Codex row");
assert!(
row.hint.contains("OAuth roster unconfirmed"),
"{}",
row.hint
);
for unsourced in ["ctx", "tools", "reasoning", "priced", "$", "per Mtok"] {
assert!(
!row.hint.contains(unsourced),
"unconfirmed row inherited {unsourced:?}: {}",
row.hint
);
}
}
#[test]
fn cross_provider_codex_row_previews_destination_route_truth() {
let (app, config, _lock) = create_test_app();
let codex_home = tempfile::tempdir().expect("Codex home");
let _codex_home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
let view = ModelPickerView::new(&app, &config);
let row = view
.model_rows
.iter()
.find(|row| {
row.provider == Some(ApiProvider::OpenaiCodex)
&& row.id == crate::config::DEFAULT_OPENAI_CODEX_MODEL
})
.expect("Codex fallback row");
assert!(
row.hint
.contains("switch route · missing login · OAuth roster missing · fallback"),
"{}",
row.hint
);
assert!(!row.hint.contains(" ctx"), "{}", row.hint);
assert!(!row.hint.contains("tools"), "{}", row.hint);
assert!(!row.hint.contains("1.05M"), "{}", row.hint);
assert!(!row.hint.contains("128K out"), "{}", row.hint);
assert!(!row.hint.contains("priced"), "{}", row.hint);
}
#[test]
fn configured_failed_provider_models_remain_visible_with_health_reason() {
let (mut app, mut config, _lock) = create_test_app();
config.providers = Some(crate::config::ProvidersConfig {
zai: crate::config::ProviderConfig {
api_key: Some("zai-test-key".to_string()),
..Default::default()
},
..Default::default()
});
app.provider_health.record_failure_message(
&config,
ApiProvider::Zai,
crate::config::ZAI_GLM_5_2_MODEL,
crate::error_taxonomy::ErrorCategory::Authentication,
"test credential rejected",
);
let view = ModelPickerView::new(&app, &config);
let row = view
.model_rows
.iter()
.find(|row| {
row.provider == Some(ApiProvider::Zai) && row.id == crate::config::ZAI_GLM_5_2_MODEL
})
.expect("configured Z.ai GLM route remains listed");
assert!(row.hint.contains("last check failed (authentication)"));
}
#[test]
fn non_active_configured_private_model_is_listed_once_and_selectable() {
let (app, mut config, _lock) = create_test_app();
let private_model = "private/acme-code-2027";
assert!(
!provider_catalog_model_ids(ApiProvider::Openrouter)
.iter()
.any(|model| model == private_model),
"fixture must stay outside the bundled/live catalog"
);
assert!(!app.provider_models.contains_key("openrouter"));
config.providers = Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("openrouter-picker-test-key".to_string()),
model: Some(private_model.to_string()),
..Default::default()
},
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
let matches = view
.model_rows
.iter()
.filter(|row| row.provider == Some(ApiProvider::Openrouter) && row.id == private_model)
.collect::<Vec<_>>();
assert_eq!(matches.len(), 1, "configured private model must be deduped");
assert!(matches[0].selectable, "{}", matches[0].hint);
view.query = private_model.to_string();
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| {
row.provider == Some(ApiProvider::Openrouter) && row.id == private_model
})
.expect("configured private model remains searchable");
assert!(matches!(
view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)),
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
provider: Some(ApiProvider::Openrouter),
..
})
));
}
#[test]
fn missing_login_and_key_rows_are_visible_but_inert() {
let (app, config, _lock) = create_test_app();
for (provider, expected_readiness) in [
(ApiProvider::Openrouter, "missing key"),
(ApiProvider::OpenaiCodex, "missing login"),
] {
let mut view = ModelPickerView::new(&app, &config);
view.query = provider.as_str().to_string();
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| row.provider == Some(provider))
.expect("unready route remains visible");
let row = view.visible_model_rows()[view.selected_model_idx];
assert!(row.hint.contains(expected_readiness), "{}", row.hint);
assert!(!row.selectable, "{}", row.hint);
assert!(matches!(
view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)),
ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth { .. })
| ViewAction::Emit(ViewEvent::StatusMessage { .. })
));
}
}
#[test]
fn invalid_candidate_is_visible_but_enter_explains() {
let (mut app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-test-key".to_string());
config.providers = Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
model: Some("anthropic/claude-foreign".to_string()),
..Default::default()
},
..Default::default()
});
app.api_provider = ApiProvider::Deepseek;
app.model = "anthropic/claude-foreign".to_string();
assert!(
!crate::provider_readiness::route_is_valid_for_model(
&config,
ApiProvider::Deepseek,
None,
),
"fixture must begin with an invalid saved route"
);
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| {
row.provider == Some(ApiProvider::Deepseek) && row.id == "anthropic/claude-foreign"
})
.expect("invalid configured model remains visible as an inert provider row");
assert!(!view.selected_model_is_selectable());
assert!(matches!(
view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)),
ViewAction::Emit(ViewEvent::ModelPickerNeedsAuth { .. })
| ViewAction::Emit(ViewEvent::StatusMessage { .. })
));
}
#[test]
fn valid_catalog_model_can_repair_an_invalid_saved_model() {
let (mut app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-test-key".to_string());
config.providers = Some(crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
model: Some("anthropic/claude-foreign".to_string()),
..Default::default()
},
..Default::default()
});
app.api_provider = ApiProvider::Deepseek;
app.model = "anthropic/claude-foreign".to_string();
assert!(
!crate::provider_readiness::route_is_valid_for_model(
&config,
ApiProvider::Deepseek,
None,
),
"fixture must begin with an invalid saved model"
);
let mut view = ModelPickerView::new(&app, &config);
view.query = "deepseek-v4-pro".to_string();
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| {
row.provider == Some(ApiProvider::Deepseek) && row.id == "deepseek-v4-pro"
})
.expect("valid DeepSeek catalog row");
let selected = view.visible_model_rows()[view.selected_model_idx];
assert!(selected.selectable, "{}", selected.hint);
assert!(
!selected.hint.contains("invalid route"),
"{}",
selected.hint
);
assert!(matches!(
view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
)),
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { .. })
));
}
#[test]
fn provider_query_splits_support_colon_and_space_forms() {
assert_eq!(
provider_query_splits("openrouter:anthropic/claude-sonnet-4"),
vec![("openrouter", "anthropic/claude-sonnet-4")]
);
assert_eq!(
provider_query_splits("openrouter anthropic/claude-sonnet-4"),
vec![("openrouter", "anthropic/claude-sonnet-4")]
);
assert_eq!(
provider_query_splits("openrouter anthropic/foo:bar"),
vec![
("openrouter anthropic/foo", "bar"),
("openrouter", "anthropic/foo:bar")
]
);
}
#[test]
fn picker_main_rows_include_saved_choices_with_provider_identity() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Together;
app.model = crate::config::DEFAULT_TOGETHER_MODEL.to_string();
app.provider_models.insert(
"openrouter".to_string(),
crate::config::DEFAULT_OPENROUTER_MODEL.to_string(),
);
let view = ModelPickerView::new(&app, &config);
let saved = view
.visible_model_rows()
.into_iter()
.find(|row| {
row.provider == Some(crate::config::ApiProvider::Openrouter)
&& row.id == crate::config::DEFAULT_OPENROUTER_MODEL
})
.expect("saved OpenRouter choice should migrate into the ordinary list");
assert_eq!(saved.id, crate::config::DEFAULT_OPENROUTER_MODEL);
let rows = view.visible_model_rows();
assert_eq!(
route_labels_for_rows(&rows)
.get("openrouter")
.map(String::as_str),
Some(crate::config::ApiProvider::Openrouter.display_name())
);
}
#[test]
fn picker_default_view_requires_an_enabled_model_not_just_a_configured_provider() {
let (mut app, _default_config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let config = Config {
providers: Some(crate::config::ProvidersConfig {
together: crate::config::ProviderConfig {
base_url: Some("https://custom.together.example/v1".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let view = ModelPickerView::new(&app, &config);
let visible_ids = view.visible_model_ids();
assert!(
view.visible_model_rows()
.iter()
.all(|row| row.provider != Some(crate::config::ApiProvider::Together)),
"configured provider without an enabled model leaked catalog rows: {visible_ids:?}"
);
assert!(visible_ids.contains(&"auto"));
assert!(visible_ids.contains(&"deepseek-v4-pro"));
let mut enabled_app = app;
enabled_app.enable_provider_model(
crate::config::ApiProvider::Together.as_str(),
crate::config::DEFAULT_TOGETHER_MODEL,
);
let enabled = ModelPickerView::new(&enabled_app, &config);
assert!(
enabled
.visible_model_ids()
.contains(&crate::config::DEFAULT_TOGETHER_MODEL),
"explicitly enabled Together model should join the ordinary chooser"
);
}
#[test]
fn picker_default_view_excludes_self_hosted_provider_without_explicit_setup() {
let (mut app, _default_config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let config = Config::default();
let view = ModelPickerView::new(&app, &config);
assert!(
!view
.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Sglang)),
"self-hosted Sglang has no explicit setup and isn't active"
);
let mut queried = ModelPickerView::new(&app, &config);
type_model_query(&mut queried, "sglang");
assert!(
queried
.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Sglang)),
"searching should still surface unconfigured providers"
);
}
#[test]
fn picker_configured_view_ignores_empty_anthropic_header_table() {
let (mut app, _default_config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let config = Config {
providers: Some(crate::config::ProvidersConfig {
anthropic: crate::config::ProviderConfig {
http_headers: Some(std::collections::HashMap::new()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let view = ModelPickerView::new(&app, &config);
assert!(
!view
.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Anthropic)),
"empty persisted headers must not pull Anthropic into Configured"
);
let mut queried = ModelPickerView::new(&app, &config);
type_model_query(&mut queried, "anthropic");
assert!(
queried
.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Anthropic)),
"full-catalog search must still discover unconfigured Anthropic routes"
);
}
#[test]
fn custom_model_row_position_accounts_for_other_configured_providers() {
let (mut app, _default_config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
app.auto_model = false;
let config = Config {
providers: Some(crate::config::ProvidersConfig {
together: crate::config::ProviderConfig {
base_url: Some("https://custom.together.example/v1".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
app.enable_provider_model(
crate::config::ApiProvider::Together.as_str(),
crate::config::DEFAULT_TOGETHER_MODEL,
);
let view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert!(
view.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Together)),
"sanity check: Together rows are actually in the default view"
);
assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
}
#[test]
fn picker_initial_selection_matches_app_state() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
}
#[test]
fn picker_keeps_active_model_selected_after_pinned_row_reordering() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.enable_provider_model(ApiProvider::Deepseek.as_str(), "deepseek-v4-flash");
app.pinned_models = vec![PinnedModel {
provider: ApiProvider::Deepseek.as_str().to_string(),
model: "deepseek-v4-flash".to_string(),
label: None,
}];
let view = ModelPickerView::new(&app, &config);
assert!(!view.show_custom_model_row);
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
assert_eq!(
view.visible_model_rows()[view.selected_model_idx].id,
"deepseek-v4-pro",
"the visible highlight must stay on the active model after pins reorder rows"
);
}
#[test]
fn muse_session_can_select_deepseek_flash_without_provider_first() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Meta;
app.model = "muse-spark-1.1".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.view, ModelListView::Configured);
type_model_query(&mut view, "deepseek v4 flash");
let flash = view
.visible_model_rows()
.iter()
.position(|row| {
row.id == "deepseek-v4-flash"
&& row.provider == Some(crate::config::ApiProvider::Deepseek)
})
.expect("typing a model name searches every provider");
view.selected_model_idx = flash;
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::Deepseek)
);
assert!(matches!(
view.build_event(),
ViewEvent::ModelPickerApplied {
provider: Some(crate::config::ApiProvider::Deepseek),
..
}
));
}
#[test]
fn stale_deepseek_alias_is_migrated_out_of_picker_choices() {
for provider in [
crate::config::ApiProvider::Deepseek,
crate::config::ApiProvider::DeepseekCN,
crate::config::ApiProvider::DeepseekAnthropic,
] {
let (mut app, config, _lock) = create_test_app();
app.api_provider = provider;
app.model = "deepseek-reasoner".to_string();
app.auto_model = false;
app.provider_models.insert(
provider.as_str().to_string(),
"deepseek-reasoner".to_string(),
);
let view = ModelPickerView::new(&app, &config);
let ids = view.visible_model_ids();
assert!(ids.contains(&"deepseek-v4-flash"), "{provider:?}");
assert!(!ids.contains(&"deepseek-chat"), "{provider:?}");
assert!(!ids.contains(&"deepseek-reasoner"), "{provider:?}");
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert!(matches!(
view.build_event(),
ViewEvent::ModelPickerApplied {
model,
previous_model,
..
} if model == "deepseek-v4-flash" && previous_model == "deepseek-reasoner"
));
let completions = provider_scoped_model_completion_ids(&app);
assert!(completions.iter().any(|id| id == "deepseek-v4-flash"));
assert!(!completions.iter().any(|id| id == "deepseek-chat"));
assert!(!completions.iter().any(|id| id == "deepseek-reasoner"));
}
}
#[test]
fn provider_native_reasoner_id_is_not_globally_rewritten() {
assert_eq!(
picker_visible_model_id(
crate::config::ApiProvider::WanjieArk,
"deepseek-reasoner",
false,
),
"deepseek-reasoner"
);
}
#[test]
fn custom_deepseek_endpoint_keeps_provider_owned_alias_in_picker() {
let (mut app, mut config, _lock) = create_test_app();
config.provider = Some("deepseek".to_string());
config.base_url = Some("https://models.example/v1".to_string());
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = config.model_ids_pass_through();
app.model = "deepseek-reasoner".to_string();
app.auto_model = false;
app.provider_models
.insert("deepseek".to_string(), "deepseek-reasoner".to_string());
let view = ModelPickerView::new(&app, &config);
let ids = view.visible_model_ids();
assert!(ids.contains(&"deepseek-reasoner"), "{ids:?}");
assert_eq!(view.resolved_model(), "deepseek-reasoner");
assert!(matches!(
view.build_event(),
ViewEvent::ModelPickerApplied { model, .. } if model == "deepseek-reasoner"
));
let completions = provider_scoped_model_completion_ids(&app);
assert!(completions.iter().any(|id| id == "deepseek-reasoner"));
}
#[test]
fn picker_initial_selection_matches_auto_state() {
let (mut app, config, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
}
#[test]
fn picker_auto_model_preserves_explicit_effort_on_apply() {
let (mut app, config, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Low;
app.reasoning_effort_preference = Some(ReasoningEffort::Low);
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
assert_eq!(view.current_efforts(), AUTO_MODEL_PICKER_EFFORTS);
}
#[test]
fn picker_model_navigation_preserves_raw_effort_request() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::High;
app.reasoning_effort_preference = Some(ReasoningEffort::Low);
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.initial_effort, ReasoningEffort::Low);
assert_eq!(
view.resolved_effort(),
ReasoningEffort::Low,
"first-party DeepSeek routes carry low as a real wire tier"
);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| row.id == "auto")
.expect("Auto row");
view.select_effort_for_current_model();
assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
assert!(matches!(
view.build_event(),
ViewEvent::ModelPickerApplied {
effort: ReasoningEffort::Low,
previous_effort: ReasoningEffort::Low,
..
}
));
}
#[test]
fn picker_auto_row_commits_visible_implicit_effort() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::High;
app.reasoning_effort_preference = None;
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.initial_effort, ReasoningEffort::Auto);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| row.id == "auto")
.expect("Auto row");
view.select_effort_for_current_model();
assert_eq!(view.resolved_effort(), ReasoningEffort::High);
assert!(matches!(
view.build_event(),
ViewEvent::ModelPickerApplied {
effort: ReasoningEffort::High,
previous_effort: ReasoningEffort::Auto,
..
}
));
}
#[test]
fn picker_normalizes_low_medium_to_high() {
let (mut app, config, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::Medium;
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
assert_eq!(
view.resolved_effort(),
ReasoningEffort::High,
"medium should map to high in the picker"
);
}
#[test]
fn picker_preserves_kimi_code_k3_low_medium_but_not_generic_moonshot() {
let (mut app, mut config, _lock) = create_test_app();
config.provider = Some("moonshot".to_string());
config.providers = Some(crate::config::ProvidersConfig {
moonshot: crate::config::ProviderConfig {
base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
model: Some("k3".to_string()),
..Default::default()
},
..Default::default()
});
app.api_provider = crate::config::ApiProvider::Moonshot;
app.model = "k3".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Medium;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_effort(), ReasoningEffort::Medium);
assert_eq!(
view.current_efforts(),
KIMI_CODE_K3_PICKER_EFFORTS.to_vec(),
"the official K3 route must expose low/medium before sending a secret"
);
config
.providers
.as_mut()
.expect("providers")
.moonshot
.base_url = Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string());
let generic = ModelPickerView::new(&app, &config);
assert_eq!(generic.resolved_effort(), ReasoningEffort::High);
assert_eq!(generic.current_efforts(), DEFAULT_PICKER_EFFORTS.to_vec());
}
#[test]
fn picker_exposes_auto_and_distinct_thinking_tiers() {
let model_labels = picker_model_ids_for_provider(crate::config::ApiProvider::Deepseek);
assert_eq!(
model_labels,
vec!["auto", "deepseek-v4-pro", "deepseek-v4-flash"]
);
let effort_labels: Vec<_> = picker_efforts_for_route(
crate::config::ApiProvider::Deepseek,
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
"deepseek-v4-pro",
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(effort_labels, vec!["auto", "off", "low", "high", "max"]);
}
#[test]
fn codex_picker_exposes_responses_reasoning_tiers() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::OpenaiCodex;
app.model = "gpt-5.5-codex".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_effort(), ReasoningEffort::Low);
let labels: Vec<_> = picker_efforts_for_route(
crate::config::ApiProvider::OpenaiCodex,
crate::config::DEFAULT_OPENAI_CODEX_BASE_URL,
"gpt-5.5-codex",
false,
)
.iter()
.map(|effort| effort.display_label_for_provider(crate::config::ApiProvider::OpenaiCodex))
.collect();
assert_eq!(labels, vec!["low", "medium", "high", "xhigh"]);
}
#[test]
fn picker_includes_saved_codex_model_as_a_provider_owned_choice() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Off;
app.provider_models
.insert("openai-codex".to_string(), "gpt-5.5".to_string());
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_effort(), ReasoningEffort::Off);
assert!(view.visible_model_rows().iter().any(|row| {
row.provider == Some(crate::config::ApiProvider::OpenaiCodex) && row.id == "gpt-5.5"
}));
}
#[test]
fn picker_navigation_previews_cross_provider_without_mutating_session() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
app.provider_models
.insert("openai-codex".to_string(), "gpt-5.5".to_string());
let mut view = ModelPickerView::new(&app, &config);
let mut saw_codex = false;
while view.move_down() {
saw_codex |= view.resolved_provider() == Some(crate::config::ApiProvider::OpenaiCodex);
}
assert!(saw_codex, "saved cross-provider choice remains navigable");
assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek);
assert_eq!(view.initial_provider, crate::config::ApiProvider::Deepseek);
}
#[test]
fn picker_query_reveals_cross_provider_route_rows() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
assert!(
view.visible_model_rows()
.iter()
.all(|row| row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::Deepseek))
);
type_model_query(&mut view, "openrouter");
assert!(
view.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Openrouter)),
"query should reveal explicit OpenRouter route rows"
);
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::Openrouter)
);
}
#[test]
fn picker_query_cross_provider_enter_emits_provider_switch() {
let (mut app, mut config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
config.providers = Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("openrouter-picker-test-key".to_string()),
..Default::default()
},
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "openrouter");
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model, provider, ..
}) => {
assert_eq!(provider, Some(crate::config::ApiProvider::Openrouter));
assert!(
!model.trim().is_empty() && model != "auto",
"cross-provider row must carry a concrete wire model"
);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn picker_query_no_match_custom_row_stays_active_provider_scoped() {
let (mut app, mut config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Openrouter;
app.model_ids_passthrough = true;
app.model = crate::config::DEFAULT_OPENROUTER_MODEL.to_string();
app.auto_model = false;
config.provider = Some("openrouter".to_string());
config.providers = Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("openrouter-picker-test-key".to_string()),
..Default::default()
},
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "custom-org/custom-model");
assert_eq!(view.resolved_model(), "custom-org/custom-model");
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::Openrouter)
);
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model, provider, ..
}) => {
assert_eq!(model, "custom-org/custom-model");
assert_eq!(provider, None, "active-provider custom row is not a switch");
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn picker_query_provider_qualified_custom_row_targets_configured_provider() {
let (mut app, _default_config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let config = Config {
providers: Some(crate::config::ProvidersConfig {
openrouter: crate::config::ProviderConfig {
api_key: Some("test-openrouter-key".to_string()),
..Default::default()
},
..Default::default()
}),
..Config::default()
};
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "openrouter:anthropic/custom-sonnet");
assert_eq!(view.resolved_model(), "anthropic/custom-sonnet");
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::Openrouter)
);
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model, provider, ..
}) => {
assert_eq!(model, "anthropic/custom-sonnet");
assert_eq!(provider, Some(crate::config::ApiProvider::Openrouter));
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn picker_query_no_match_strict_provider_enter_is_noop() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "definitely-not-a-deepseek-model");
assert_eq!(view.model_row_count(), 0);
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(action, ViewAction::None));
}
#[test]
fn picker_query_backspace_restores_active_provider_rows() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "openrouter");
assert!(
view.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Openrouter))
);
for _ in 0.."openrouter".len() {
view.handle_key(KeyEvent::new(
KeyCode::Backspace,
crossterm::event::KeyModifiers::NONE,
));
}
assert!(view.query.is_empty());
assert!(
view.visible_model_rows()
.iter()
.all(|row| row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::Deepseek))
);
}
#[test]
fn picker_effort_pane_ignores_query_typing() {
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
type_model_query(&mut view, "openrouter");
assert_eq!(view.focus, Pane::Effort);
assert!(view.query.is_empty());
assert!(
view.visible_model_rows()
.iter()
.all(|row| row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::Deepseek))
);
}
#[test]
fn picker_query_resyncs_effort_for_codex_rows() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Auto;
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
type_model_query(&mut view, "codex");
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::OpenaiCodex)
);
assert_eq!(
view.resolved_effort(),
ReasoningEffort::Medium,
"OpenAI Codex rows should normalize auto to medium"
);
}
#[test]
fn picker_query_ranks_provider_matches_before_id_substrings() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "zai");
let rows = view.visible_model_rows();
let first = rows.first().expect("zai query should surface rows");
assert_eq!(first.provider, Some(ApiProvider::Zai));
view.update_query(String::new());
type_model_query(&mut view, "deepseek-v4-p");
let ids: Vec<&str> = view
.visible_model_rows()
.iter()
.map(|row| row.id.as_str())
.collect();
assert!(!ids.is_empty(), "deepseek-v4-p query should match rows");
assert_eq!(
ids.first().copied(),
Some("deepseek-v4-pro"),
"prefix match must outrank substring match: {ids:?}"
);
}
fn cross_provider_row() -> ModelPickerRow {
ModelPickerRow {
id: "z-ai/glm-5.2".to_string(),
provider: Some(ApiProvider::Zai),
provider_identity: None,
hint: "switch route · reasoning".to_string(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
}
}
#[test]
fn model_row_query_matches_provider_name() {
let row = cross_provider_row();
assert!(model_row_matches_query(&row, "zai", ApiProvider::Deepseek));
assert!(model_row_matches_query(
&row,
"zhipu",
ApiProvider::Deepseek
));
assert!(model_row_matches_query(
&row,
"ZHIPU",
ApiProvider::Deepseek
));
}
#[test]
fn model_row_query_matches_display_model_name() {
let row = cross_provider_row();
assert!(model_row_matches_query(
&row,
"glm-5.2",
ApiProvider::Deepseek
));
assert!(model_row_matches_query(&row, "GLM", ApiProvider::Deepseek));
}
#[test]
fn model_row_query_matches_wire_model_id() {
let row = cross_provider_row();
assert!(model_row_matches_query(
&row,
"z-ai/glm-5.2",
ApiProvider::Deepseek
));
assert!(model_row_matches_query(
&row,
"z-ai/",
ApiProvider::Deepseek
));
}
#[test]
fn model_row_query_treats_hyphens_like_human_word_breaks() {
let row = ModelPickerRow {
id: "deepseek-v4-flash".to_string(),
provider: Some(ApiProvider::Deepseek),
provider_identity: None,
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: true,
blocked_reason: None,
enabled: true,
};
assert!(model_row_matches_query(
&row,
"deepseek v4 flash",
ApiProvider::Meta
));
}
#[test]
fn model_row_query_no_field_match_returns_false() {
let row = cross_provider_row();
assert!(!model_row_matches_query(
&row,
"openai",
ApiProvider::Deepseek
));
}
#[test]
fn picker_query_by_wire_id_surfaces_cross_provider_row_and_hides_others() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
type_model_query(&mut view, "glm");
assert!(
view.visible_model_rows()
.iter()
.any(|row| row.provider == Some(crate::config::ApiProvider::Zai)),
"searching a model name must surface the provider that serves it"
);
view.update_query(String::new());
type_model_query(&mut view, "zzz-no-such-provider-or-model");
assert!(
view.visible_model_rows().is_empty(),
"a query matching no provider/model/wire field must return no rows"
);
}
#[test]
fn picker_preserves_unknown_model_via_custom_row() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
}
#[test]
fn picker_lists_openrouter_catalog_models() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Openrouter;
app.model_ids_passthrough = true;
app.model = "minimax/minimax-m3".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let model_ids: Vec<_> = view
.model_rows
.iter()
.filter(|row| row.provider == Some(crate::config::ApiProvider::Openrouter))
.map(|row| row.id.as_str())
.collect();
for expected in [
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
"qwen/qwen3.6-flash",
"qwen/qwen3.7-plus",
"minimax/minimax-m3",
] {
assert!(
model_ids.contains(&expected),
"missing {expected}: {model_ids:?}"
);
}
assert!(!view.show_custom_model_row);
assert_eq!(view.resolved_model(), "minimax/minimax-m3");
}
#[test]
fn v090_picker_metadata_preserves_unknown_catalog_limits_and_prices() {
let config = Config::default();
let qwen =
effective_picker_metadata(&config, Some(ApiProvider::Openrouter), "qwen/qwen3.7-plus");
assert_eq!(qwen.context_window, None);
assert_eq!(qwen.max_output, None);
assert!(qwen.reasoning);
assert!(matches!(qwen.pricing, PickerPricing::Known(_)));
let trinity = effective_picker_metadata(&config, Some(ApiProvider::Arcee), "trinity-mini");
assert_eq!(trinity.context_window, Some(128_000));
assert_eq!(trinity.max_output, None);
assert!(trinity.reasoning);
assert_eq!(trinity.pricing, PickerPricing::Unknown);
let inkling = effective_picker_metadata(
&config,
Some(ApiProvider::Together),
crate::config::TOGETHER_INKLING_MODEL,
);
assert_eq!(inkling.context_window, None);
assert_eq!(inkling.max_output, None);
assert!(inkling.reasoning);
assert_eq!(inkling.pricing, PickerPricing::Unknown);
}
#[test]
fn picker_lists_xiaomi_mimo_chat_models_without_speech_models() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::XiaomiMimo;
app.model = "mimo-v2.5-pro".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let model_ids: Vec<_> = view
.model_rows
.iter()
.filter(|row| row.provider == Some(crate::config::ApiProvider::XiaomiMimo))
.map(|row| row.id.as_str())
.collect();
for expected in ["mimo-v2.5-pro", "mimo-v2.5"] {
assert!(model_ids.contains(&expected), "missing {expected}");
}
for deprecated in ["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"] {
assert!(
!model_ids.contains(&deprecated),
"{deprecated} is deprecated and should not be promoted"
);
}
for speech_model in [
"mimo-v2.5-tts",
"mimo-v2.5-tts-voicedesign",
"mimo-v2.5-tts-voiceclone",
"mimo-v2-tts",
] {
assert!(
!model_ids.contains(&speech_model),
"{speech_model} should not appear in the chat model picker"
);
}
}
#[test]
fn picker_lists_current_opencode_go_chat_models_only() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::OpencodeGo;
app.model = crate::config::DEFAULT_OPENCODE_GO_MODEL.to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let model_ids: Vec<_> = view
.model_rows
.iter()
.filter(|row| row.provider == Some(crate::config::ApiProvider::OpencodeGo))
.map(|row| row.id.as_str())
.collect();
for expected in ["grok-4.5", "kimi-k3"] {
assert!(
model_ids.contains(&expected),
"missing {expected}: {model_ids:?}"
);
}
for messages_only in ["minimax-m3", "qwen3.7-max"] {
assert!(
!model_ids.contains(&messages_only),
"{messages_only} must remain excluded from the Chat-only picker"
);
}
}
#[test]
fn picker_lists_grok_46_first_and_keeps_grok_45_selectable() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Xai;
app.model = crate::config::XAI_GROK_4_6_MODEL.to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let ids: Vec<_> = view
.model_rows
.iter()
.filter(|row| row.provider == Some(crate::config::ApiProvider::Xai))
.map(|row| row.id.as_str())
.collect();
assert_eq!(ids.first().copied(), Some("grok-4.6"));
assert!(ids.contains(&"grok-4.5"));
}
#[test]
fn picker_for_ollama_preserves_current_local_tag_without_hosted_static_rows() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Ollama;
app.model_ids_passthrough = true;
app.model = "qwen2.5-coder:7b".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let model_ids = view.visible_model_ids();
assert_eq!(model_ids, vec!["auto"]);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "qwen2.5-coder:7b");
}
#[test]
fn visible_row_window_tracks_selection_in_short_panes() {
assert_eq!(visible_row_window(0, 16, 8), (0, 8));
assert_eq!(visible_row_window(7, 16, 8), (3, 11));
assert_eq!(visible_row_window(15, 16, 8), (8, 16));
assert_eq!(visible_row_window(3, 4, 8), (0, 4));
assert_eq!(visible_row_window(3, 4, 0), (0, 0));
}
#[test]
fn narrow_picker_rows_hide_hint_before_clipping_model_id() {
let row = PaneRow::effort(
"minimax/minimax-m3".to_string(),
"1M multimodal".to_string(),
);
let spans = picker_row_spans(
&row,
"▸",
24,
ModelRowColumns::for_page(std::slice::from_ref(&row)),
Style::default(),
Style::default(),
);
let rendered = spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("minimax/minimax-m3"));
assert!(!rendered.contains("1M multimodal"));
assert!(unicode_width::UnicodeWidthStr::width(rendered.as_str()) <= 24);
}
#[test]
fn picker_preserves_custom_passthrough_model_ids() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Openrouter;
app.model_ids_passthrough = true;
app.model = "opencode-go/glm-5.1".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "opencode-go/glm-5.1");
}
#[test]
fn picker_exposes_active_custom_provider_model_row() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Custom;
app.model_ids_passthrough = true;
app.model = "vendor/custom-model-v1".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "vendor/custom-model-v1");
assert_eq!(
view.resolved_provider(),
Some(crate::config::ApiProvider::Custom)
);
}
#[test]
fn named_custom_picker_event_keeps_exact_target_identity() {
let (mut app, mut config, _lock) = create_test_app();
app.set_provider_identity(crate::config::ApiProvider::Custom, "custom-a");
app.model_ids_passthrough = true;
app.model = "model-a".to_string();
app.auto_model = false;
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
api_key: Some("local-test-key".to_string()),
..Default::default()
},
);
}
config.provider = Some("custom-b".to_string());
config.providers = Some(crate::config::ProvidersConfig {
custom,
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| row.provider == Some(ApiProvider::Custom) && row.id == "model-b")
.expect("custom B row");
app.pinned_models = vec![PinnedModel {
provider: "custom-a".to_string(),
model: "model-b".to_string(),
label: Some("A's pin".to_string()),
}];
app.set_provider_identity(crate::config::ApiProvider::Custom, "custom-b");
let rows = picker_model_rows_for_app(&app, &config);
assert!(rows.iter().any(|row| {
row.provider == Some(ApiProvider::Custom)
&& row.provider_identity.as_deref() == Some("custom-b")
&& row.id == "model-b"
}));
assert!(rows.iter().any(|row| {
row.provider == Some(ApiProvider::Custom)
&& row.provider_identity.as_deref() == Some("custom-a")
&& row.id == "model-b"
&& row.hint.contains("stale pinned")
}));
match view.build_event() {
ViewEvent::ModelPickerApplied {
model,
provider,
provider_id,
..
} => {
assert_eq!(model, "model-b");
assert_eq!(provider, None, "both routes share the Custom enum");
assert_eq!(provider_id.as_deref(), Some("custom-b"));
}
other => panic!("expected model picker apply event, got {other:?}"),
}
}
#[test]
fn picker_exposes_saved_model_for_active_provider() {
let (mut app, mut config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::XiaomiMimo;
app.model = "mimo-v2.5-custom".to_string();
app.auto_model = false;
app.provider_models
.insert("xiaomi-mimo".to_string(), "mimo-v2.5-custom".to_string());
config.provider = Some("xiaomi-mimo".to_string());
config.providers = Some(crate::config::ProvidersConfig {
xiaomi_mimo: crate::config::ProviderConfig {
api_key: Some("mimo-picker-test-key".to_string()),
..Default::default()
},
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| {
row.id == "mimo-v2.5-custom"
&& row.provider == Some(crate::config::ApiProvider::XiaomiMimo)
})
.expect("saved Xiaomi MiMo model row");
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model, provider, ..
}) => {
assert_eq!(model, "mimo-v2.5-custom");
assert_eq!(provider, None);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn picker_migrates_saved_models_from_other_providers() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::XiaomiMimo;
app.model = "mimo-v2.5-pro".to_string();
app.auto_model = false;
app.provider_models
.insert("deepseek".to_string(), "deepseek-v4-pro".to_string());
app.provider_models
.insert("moonshot".to_string(), "kimi-k2.6".to_string());
app.provider_models
.insert("openai".to_string(), "qwen-plus".to_string());
app.provider_models.insert(
"qianfan".to_string(),
"custom-qianfan-service-id".to_string(),
);
let view = ModelPickerView::new(&app, &config);
let model_ids = view.visible_model_ids();
assert!(model_ids.contains(&"mimo-v2.5-pro"));
assert!(model_ids.contains(&"deepseek-v4-pro"));
assert!(model_ids.contains(&"kimi-k2.6"));
assert!(model_ids.contains(&"qwen-plus"));
assert!(model_ids.contains(&"custom-qianfan-service-id"));
assert!(!view.show_custom_model_row);
assert!(view.visible_model_rows().iter().all(|row| {
row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::XiaomiMimo)
|| app
.provider_models
.contains_key(row.provider.unwrap().as_str())
}));
}
#[test]
fn picker_skips_unknown_provider_saved_models() {
let (mut app, config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::XiaomiMimo;
app.model = "mimo-v2.5-pro".to_string();
app.auto_model = false;
app.provider_models
.insert("totally-unknown".to_string(), "ghost-model".to_string());
let view = ModelPickerView::new(&app, &config);
assert!(!view.visible_model_ids().contains(&"ghost-model"));
}
#[test]
fn picker_does_not_hijack_current_custom_model_with_saved_provider_row() {
let (mut app, mut config, _lock) = create_test_app();
app.api_provider = crate::config::ApiProvider::Openai;
app.model_ids_passthrough = true;
app.model = "kimi-k2.6".to_string();
app.provider_models
.insert("moonshot".to_string(), "kimi-k2.6".to_string());
config.provider = Some("openai".to_string());
config.providers = Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
api_key: Some("openai-picker-test-key".to_string()),
..Default::default()
},
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert_eq!(view.resolved_model(), "kimi-k2.6");
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model, provider, ..
}) => {
assert_eq!(model, "kimi-k2.6");
assert_eq!(provider, None);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn arrow_keys_move_within_focused_pane() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.enable_provider_model("deepseek", "deepseek-v4-flash");
app.reasoning_effort = ReasoningEffort::High;
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 1);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_model_idx, 2);
view.handle_key(KeyEvent::new(
KeyCode::Up,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_model_idx, 1);
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.focus, Pane::Effort);
assert_eq!(view.selected_effort_idx, 3);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_effort_idx, 4);
}
#[test]
fn mouse_wheel_moves_focused_picker_pane() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.enable_provider_model("deepseek", "deepseek-v4-flash");
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 1);
view.handle_mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_eq!(view.selected_model_idx, 2);
view.handle_mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::NONE,
});
assert_eq!(view.selected_model_idx, 1);
}
#[test]
fn mouse_click_focuses_row_and_second_click_applies() {
let (app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-picker-test-key".to_string());
let mut view = ModelPickerView::new(&app, &config);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let (rect, pane, idx) = view
.row_hitboxes
.borrow()
.iter()
.find(|(_, pane, idx)| *pane == Pane::Effort && *idx == 0)
.copied()
.expect("first effort row should be clickable");
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: rect.x,
row: rect.y,
modifiers: crossterm::event::KeyModifiers::NONE,
};
assert!(matches!(view.handle_mouse(click), ViewAction::None));
assert_eq!(view.focus, pane);
assert_eq!(view.selected_effort_idx, idx);
assert!(matches!(
view.handle_mouse(click),
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied { .. })
));
}
#[test]
fn tab_switches_between_model_and_thinking() {
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.focus, Pane::Model);
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.focus, Pane::Effort);
view.handle_key(KeyEvent::new(
KeyCode::BackTab,
crossterm::event::KeyModifiers::SHIFT,
));
assert_eq!(view.focus, Pane::Model);
}
#[test]
fn enter_emits_current_model_and_thinking() {
let (mut app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-picker-test-key".to_string());
app.reasoning_effort = ReasoningEffort::High;
app.reasoning_effort_preference = Some(ReasoningEffort::High);
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.enable_provider_model("deepseek", "deepseek-v4-flash");
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 1);
assert_eq!(view.selected_effort_idx, 3);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
view.handle_key(KeyEvent::new(
KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
));
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
let action = view.handle_key(KeyEvent::new(
KeyCode::Enter,
crossterm::event::KeyModifiers::NONE,
));
match action {
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
model,
effort,
previous_effort,
..
}) => {
assert_eq!(model, "deepseek-v4-flash");
assert_eq!(effort, ReasoningEffort::Max);
assert_eq!(previous_effort, ReasoningEffort::High);
}
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
}
}
#[test]
fn shift_d_emits_an_explicit_startup_default_save() {
let (mut app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-picker-test-key".to_string());
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
let action = view.handle_key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
save_as_startup_default: true,
..
})
));
}
#[test]
fn deepseek_provider_uses_neutral_two_pane_selection() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Max;
app.enable_provider_model("deepseek", "deepseek-v4-pro");
app.enable_provider_model("deepseek", "deepseek-v4-flash");
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 2);
assert_eq!(view.selected_effort_idx, 4);
assert_eq!(view.focus, Pane::Model);
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
}
#[test]
fn model_picker_selected_row_renders_readable_selection_contrast() {
let (mut app, mut config, _lock) = create_test_app();
config.api_key = Some("deepseek-picker-test-key".to_string());
app.model = "deepseek-v4-flash".to_string();
app.auto_model = false;
let view = ModelPickerView::new(&app, &config);
let area = Rect::new(0, 0, 100, 28);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let y = row_containing(&buf, area, "deepseek-v4-flash")
.expect("selected model row should render");
let highlighted_cells = (area.x..area.x.saturating_add(area.width))
.filter(|&x| {
let cell = &buf[(x, y)];
!cell.symbol().trim().is_empty()
&& cell.bg == palette::SELECTION_BG
&& cell.fg == palette::SELECTION_TEXT
})
.count();
assert!(
highlighted_cells >= "deepseek-v4-flash".len(),
"selected /model row should use readable selection text"
);
assert!(
!(area.x..area.x.saturating_add(area.width))
.any(|x| buf[(x, y)].bg == palette::WHALE_ACTION),
"selected /model row should not use the bright accent background"
);
}
#[test]
fn known_model_with_auto_effort_preserves_explicit_model() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app, &config);
assert!(!view.show_custom_model_row);
assert_eq!(view.selected_model_idx, 1);
assert_eq!(view.selected_effort_idx, 0);
assert_eq!(view.resolved_model(), "deepseek-v4-pro");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
}
#[test]
fn auto_model_selects_auto_row() {
let (mut app, config, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Auto;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 0);
assert_eq!(view.selected_effort_idx, 0);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_effort(), ReasoningEffort::Auto);
}
#[test]
fn custom_model_row_preserves_current_model_and_effort() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
app.auto_model = false;
app.reasoning_effort = ReasoningEffort::High;
let view = ModelPickerView::new(&app, &config);
assert!(view.show_custom_model_row);
assert_eq!(view.selected_model_idx, view.visible_model_rows().len());
assert_eq!(view.selected_effort_idx, 3);
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
assert_eq!(view.resolved_effort(), ReasoningEffort::High);
}
#[test]
fn move_down_from_last_model_is_noop() {
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = view.model_row_count() - 1;
let result = view.move_down();
assert!(!result);
}
#[test]
fn move_up_from_first_model_is_noop() {
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = 0;
let result = view.move_up();
assert!(!result);
}
#[test]
fn immediate_esc_closes_without_apply() {
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
let action = view.handle_key(KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
));
}
#[test]
fn esc_after_selection_move_closes_without_apply() {
let (mut app, config, _lock) = create_test_app();
app.reasoning_effort = ReasoningEffort::High;
let mut view = ModelPickerView::new(&app, &config);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
let action = view.handle_key(KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
));
}
#[test]
fn esc_reports_browsing_context_and_reopen_restores_it() {
let (mut app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
view.handle_key(KeyEvent::new(
KeyCode::Char('a'),
crossterm::event::KeyModifiers::NONE,
));
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
let browsed_id = view.resolved_model();
let action = view.handle_key(KeyEvent::new(
KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
let ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed {
catalog_view,
view,
selected_row_id,
}) = action
else {
panic!("expected ModelPickerDismissed, got something else");
};
assert!(catalog_view, "catalog view should be remembered");
assert_eq!(view, "catalog");
assert_eq!(selected_row_id.as_deref(), Some(browsed_id.as_str()));
app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
catalog_view,
view: Some(view),
selected_row_id,
});
let reopened = ModelPickerView::new(&app, &config);
assert_eq!(reopened.view, ModelListView::Catalog);
assert_eq!(reopened.resolved_model(), browsed_id);
}
#[test]
fn reopen_with_stale_memory_falls_back_to_active_model() {
let (mut app, config, _lock) = create_test_app();
app.model_picker_memory = Some(crate::tui::app::ModelPickerMemory {
catalog_view: false,
view: Some("configured".to_string()),
selected_row_id: Some("model-that-no-longer-exists".to_string()),
});
let view = ModelPickerView::new(&app, &config);
assert!(view.selected_model_idx <= view.model_row_count());
}
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
#[test]
fn toggle_view_cycles_six_catalog_views() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
let configured_count = view.visible_model_rows().len();
assert_eq!(view.view, ModelListView::Configured);
view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
assert_eq!(view.view, ModelListView::Catalog);
assert!(view.visible_model_rows().len() > configured_count);
let expected = [
ModelListView::Recent,
ModelListView::Coding,
ModelListView::Cheap,
ModelListView::LongContext,
ModelListView::Configured,
];
for expected_view in expected {
view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
assert_eq!(view.view, expected_view);
}
assert_eq!(view.visible_model_rows().len(), configured_count);
}
#[test]
fn discoverability_views_do_not_auto_select_newest() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (app, config, _lock) = create_test_app();
let mut view = ModelPickerView::new(&app, &config);
let active = view.resolved_model();
for _ in 0..2 {
view.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
}
assert_eq!(view.view, ModelListView::Recent);
assert_eq!(view.selected_model_idx, 0);
let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ModelPickerDismissed { .. })
));
assert_eq!(active, app.model);
}
#[test]
fn model_picker_empty_state_and_footer_localize_in_complete_locales() {
use crate::localization::{Locale, MessageId, tr};
use crate::tui::views::ViewStack;
let compact = |s: &str| -> String { s.chars().filter(|c| !c.is_whitespace()).collect() };
for locale in Locale::shipped_complete() {
let (mut app, config, _lock) = create_test_app();
app.ui_locale = *locale;
let mut view = ModelPickerView::new(&app, &config);
view.query = "zzz-no-such-model-xyzzy".into();
view.focus = Pane::Model;
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
let mut stack = ViewStack::new();
stack.push(view);
stack.render(area, &mut buf);
let mut text = String::new();
for y in 0..area.height {
for x in 0..area.width {
text.push_str(buf[(x, y)].symbol());
}
}
let expected_match = tr(*locale, MessageId::RouteNoModelMatch)
.replace("{query}", "zzz-no-such-model-xyzzy");
let compact_text = compact(&text);
assert!(
compact_text.contains("zzz-no-such-model-xyzzy"),
"{} missing empty-state query; got: {text}",
locale.tag()
);
let leading = expected_match
.split(['—', '–', '-'])
.next()
.unwrap_or(expected_match.as_str());
let leading_compact = compact(leading);
let leading_prefix: String = leading_compact.chars().take(24).collect();
assert!(
!leading_prefix.is_empty() && compact_text.contains(&leading_prefix),
"{} missing localized no-match empty state prefix {leading_prefix:?}; got: {text}",
locale.tag()
);
if *locale != Locale::En {
let en_match = tr(Locale::En, MessageId::RouteNoModelMatch)
.replace("{query}", "zzz-no-such-model-xyzzy");
assert_ne!(
expected_match,
en_match,
"{} empty-state translation must not be English",
locale.tag()
);
assert!(
!compact_text.contains(&compact("No models match")),
"{} leaked English empty-state copy",
locale.tag()
);
}
for id in [
MessageId::PickerActionMove,
MessageId::PickerActionSwitch,
MessageId::PickerActionApply,
MessageId::PickerActionCancel,
] {
let label = tr(*locale, id);
assert!(
compact_text.contains(&compact(label.as_ref())),
"{} missing footer label for {id:?}: {label}",
locale.tag()
);
}
}
}
#[test]
fn model_picker_is_usable_and_opaque_at_blocker_sizes() {
use crate::tui::views::ViewStack;
let (app, config, _lock) = create_test_app();
for (w, h) in BLOCKER_SIZES {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol("X");
}
}
let mut stack = ViewStack::new();
stack.push(ModelPickerView::new(&app, &config));
stack.render(area, &mut buf);
let rows: Vec<String> = (0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
let text = rows.join("\n");
for label in [
"move",
"switch",
"search any model",
"apply",
"browse catalog",
"cancel",
] {
assert!(text.contains(label), "{w}x{h}: missing '{label}' hint");
}
for label in ["Model", "Thinking"] {
assert!(text.contains(label), "{w}x{h}: missing '{label}' pane");
}
assert!(
!text.contains('X'),
"{w}x{h}: background bleed-through into modal surface"
);
assert_eq!(
buf[(w / 2, h / 2)].bg,
palette::WHALE_BG,
"{w}x{h}: modal interior must be opaque"
);
for (y, row) in rows.iter().enumerate() {
assert!(
unicode_width::UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{w}x{h}: row {y} overflows width: {row:?}"
);
}
}
}
#[test]
fn deepseek_picker_exposes_the_documented_wire_ladder() {
let labels: Vec<&str> = picker_efforts_for_route(
crate::config::ApiProvider::Deepseek,
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
"deepseek-v4-pro",
false,
)
.iter()
.map(|effort| effort.short_label())
.collect();
assert_eq!(labels, vec!["auto", "off", "low", "high", "max"]);
}
#[test]
fn catalog_effort_values_map_provider_vocabularies() {
assert_eq!(catalog_effort_value("none"), Some(ReasoningEffort::Off));
assert_eq!(
catalog_effort_value("minimal"),
Some(ReasoningEffort::Minimal)
);
assert_eq!(
catalog_effort_value("adaptive"),
Some(ReasoningEffort::Auto)
);
assert_eq!(catalog_effort_value("xhigh"), Some(ReasoningEffort::XHigh));
assert_eq!(
catalog_effort_value("always_on"),
Some(ReasoningEffort::Max)
);
assert_eq!(catalog_effort_value("mystery"), None);
}
#[test]
fn picker_uses_catalog_reasoning_options_when_present() {
let labels: Vec<&str> = picker_efforts_for_route(
crate::config::ApiProvider::Zai,
crate::config::ApiProvider::Zai.default_base_url(),
"GLM-5.2",
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(
labels,
vec!["auto", "high", "max"],
"catalog effort list must drive the Thinking pane (plus Auto)"
);
}
#[test]
fn picker_uses_catalog_thinking_options_for_minimax() {
let labels: Vec<&str> = picker_efforts_for_route(
crate::config::ApiProvider::Minimax,
crate::config::ApiProvider::Minimax.default_base_url(),
"MiniMax-M3",
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(labels, vec!["auto", "off"]);
}
#[test]
fn picker_walks_the_catalogued_ladder_for_grok_4_5() {
let labels: Vec<&str> = picker_efforts_for_route(
crate::config::ApiProvider::Xai,
crate::config::ApiProvider::Xai.default_base_url(),
"grok-4.5",
false,
)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(
labels,
vec!["auto", "low", "medium", "high"],
"grok-4.5's documented ladder, not the generic DEFAULT_PICKER_EFFORTS"
);
}
#[test]
fn single_visible_row_pane_title_shows_single_position_not_degenerate_range() {
let (app, config, _lock) = create_test_app();
let view = ModelPickerView::new(&app, &config);
let rows: Vec<PaneRow> = (1..=3)
.map(|n| PaneRow::effort(format!("model-{n}"), String::new()))
.collect();
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
view.render_pane(
area,
&mut buf,
"Model",
rows,
PaneRenderState {
pane: Pane::Model,
selected: 1,
focused: false,
},
);
let title = buffer_row_text(&buf, area, area.y);
assert!(
title.contains("Model 2/3"),
"single visible row should show a single position: {title:?}"
);
assert!(
!title.contains("2-2/3"),
"single visible row must not render a degenerate range: {title:?}"
);
}
#[test]
fn multi_visible_row_pane_title_keeps_real_range() {
let (app, config, _lock) = create_test_app();
let view = ModelPickerView::new(&app, &config);
let rows: Vec<PaneRow> = (1..=4)
.map(|n| PaneRow::effort(format!("model-{n}"), String::new()))
.collect();
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
view.render_pane(
area,
&mut buf,
"Thinking",
rows,
PaneRenderState {
pane: Pane::Effort,
selected: 2,
focused: false,
},
);
let title = buffer_row_text(&buf, area, area.y);
assert!(
title.contains("Thinking 2-3/4"),
"multi visible row should render a real range: {title:?}"
);
}
#[test]
fn route_discriminator_handles_ascii_and_non_ascii_display_names() {
assert_eq!(
route_discriminator("DeepSeek", "deepseek-anthropic"),
Some("anthropic".to_string())
);
assert_eq!(route_discriminator("DeepSeek", "deepseek"), None);
assert_eq!(
route_discriminator("深度求索", "深度求索-anthropic"),
Some("anthropic".to_string())
);
}
}