use std::cell::RefCell;
use crossterm::event::{KeyCode, KeyEvent, 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_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::tui::app::{App, ReasoningEffort};
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 CODEX_PICKER_EFFORTS: &[ReasoningEffort] = &[
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Max,
];
const AUTO_MODEL_PICKER_EFFORTS: &[ReasoningEffort] = &[ReasoningEffort::Auto];
#[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,
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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ModelPickerRow {
id: String,
provider: Option<ApiProvider>,
hint: String,
metadata: EffectivePickerMetadata,
selectable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct EffectivePickerMetadata {
context_window: Option<u32>,
max_output: Option<u32>,
tool_calls: Option<bool>,
reasoning: bool,
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 default_visible_rows: Vec<_> = model_rows
.iter()
.filter(|row| {
model_row_visible_in_view(
row,
app.api_provider,
&configured_providers,
ModelListView::Configured,
)
})
.collect();
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;
let effort_rows = picker_efforts_for_provider(app.api_provider, app.auto_model);
let normalized = normalize_picker_effort(initial_effort, app.api_provider, app.auto_model);
let selected_effort_idx = effort_rows
.iter()
.position(|e| *e == normalized)
.unwrap_or_else(|| default_picker_effort_idx(app.api_provider, app.auto_model));
let mut view = Self {
initial_model,
previous_model,
initial_provider: app.api_provider,
initial_effort,
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,
};
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 {
let effort = self.resolved_effort();
self.selected_model_idx = position;
self.select_effort_for_current_model(effort);
}
}
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.initial_provider,
&self.configured_providers,
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);
}
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 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 {
if self.resolved_model().trim().eq_ignore_ascii_case("auto") {
return ReasoningEffort::Auto;
}
let efforts = self.current_efforts();
efforts[self
.selected_effort_idx
.min(efforts.len().saturating_sub(1))]
}
fn current_efforts(&self) -> &'static [ReasoningEffort] {
picker_efforts_for_provider(
self.resolved_provider().unwrap_or(self.initial_provider),
self.resolved_model().trim().eq_ignore_ascii_case("auto"),
)
}
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) {
let effort = self.resolved_effort();
self.query = next;
self.selected_model_idx = 0;
self.clamp_model_selection();
self.select_effort_for_current_model(effort);
}
fn select_effort_for_current_model(&mut self, effort: ReasoningEffort) {
let provider = self.resolved_provider().unwrap_or(self.initial_provider);
let model_is_auto = self.resolved_model().trim().eq_ignore_ascii_case("auto");
let normalized = normalize_picker_effort(effort, provider, model_is_auto);
self.selected_effort_idx = picker_efforts_for_provider(provider, model_is_auto)
.iter()
.position(|candidate| *candidate == normalized)
.unwrap_or_else(|| default_picker_effort_idx(provider, model_is_auto));
}
fn move_up(&mut self) -> bool {
match self.focus {
Pane::Model => {
if self.selected_model_idx > 0 {
let effort = self.resolved_effort();
self.selected_model_idx -= 1;
self.select_effort_for_current_model(effort);
return true;
}
}
Pane::Effort => {
if self.selected_effort_idx > 0 {
self.selected_effort_idx -= 1;
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 {
let effort = self.resolved_effort();
self.selected_model_idx += 1;
self.select_effort_for_current_model(effort);
return true;
}
}
Pane::Effort => {
let max = self.current_efforts().len().saturating_sub(1);
if self.selected_effort_idx < max {
self.selected_effort_idx += 1;
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();
let effort = self.resolved_effort();
self.selected_model_idx = 0;
self.clamp_model_selection();
self.select_effort_for_current_model(effort);
}
fn build_event(&self) -> 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.resolved_effort(),
previous_model: self.previous_model.clone(),
previous_effort: self.initial_effort,
}
}
fn render_pane(
&self,
area: Rect,
buf: &mut Buffer,
title: &str,
rows: Vec<(String, String)>,
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 mut lines = Vec::with_capacity(end.saturating_sub(start));
for (idx, (label, hint)) in rows.iter().enumerate().skip(start).take(end - start) {
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 is_selected = idx == state.selected;
let marker = if is_selected { "â–¸" } else { " " };
let label_style = if is_selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let hint_style = if is_selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let spans = picker_row_spans(
label,
hint,
marker,
usize::from(inner.width),
label_style,
hint_style,
);
lines.push(Line::from(spans));
}
if rows.is_empty() {
let message = if self.query.is_empty() {
"No models available.".to_string()
} else {
format!("No models match \"{}\" — Backspace to clear.", self.query)
};
lines.push(Line::from(Span::styled(
message,
Style::default().fg(palette::TEXT_MUTED),
)));
}
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)
}
fn picker_row_spans<'a>(
label: &'a str,
hint: &'a str,
marker: &'static str,
width: usize,
label_style: Style,
hint_style: Style,
) -> Vec<Span<'a>> {
let prefix_width = 3;
let label_width = width.saturating_sub(prefix_width);
let label = fit_text(label, label_width);
let mut spans = vec![
Span::styled(" ", label_style),
Span::styled(marker, label_style),
Span::styled(" ", label_style),
Span::styled(label, label_style),
];
if !hint.is_empty() {
let hint_text = format!(" ({hint})");
let used = prefix_width
+ unicode_width::UnicodeWidthStr::width(
spans
.last()
.map(|span| span.content.as_ref())
.unwrap_or_default(),
);
if used + unicode_width::UnicodeWidthStr::width(hint_text.as_str()) <= width {
spans.push(Span::styled(hint_text, 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 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,
);
}
rows
}
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 {
let readiness =
crate::provider_readiness::resolve_for_model(config, provider, &id, provider_health);
let selectable = readiness.can_attempt();
let readiness_label = readiness.label();
if id == "auto" {
let metadata = effective_picker_metadata(config, None, "auto");
let hint = format!(
"{} · {}",
readiness_label,
render_picker_model_hint("auto", None, &metadata, None)
);
push_model_row(rows, id, None, hint, metadata, selectable);
} else {
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!("{} · {hint}", readiness_label);
if provider != active_provider {
hint = format!("switch route · {hint}");
}
push_model_row(rows, id.clone(), Some(provider), hint, metadata, selectable);
}
}
}
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,
) {
if rows
.iter()
.any(|row| row.id == id && row.provider == provider)
{
return;
}
rows.push(ModelPickerRow {
id,
provider,
hint,
metadata,
selectable,
});
}
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| 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 model_row_label(row: &ModelPickerRow, initial_provider: ApiProvider) -> String {
match row.provider {
Some(provider) if provider != initial_provider => {
format!("{} · {}", provider.display_name(), row.id)
}
_ => row.id.clone(),
}
}
fn model_row_visible_in_view(
row: &ModelPickerRow,
initial_provider: ApiProvider,
configured_providers: &[ApiProvider],
view: ModelListView,
) -> bool {
match view {
ModelListView::Configured => {
model_row_visible_by_default(row.provider, initial_provider, configured_providers)
}
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_provider: Option<ApiProvider>,
initial_provider: ApiProvider,
configured_providers: &[ApiProvider],
) -> bool {
match row_provider {
None => true,
Some(provider) => provider == initial_provider || configured_providers.contains(&provider),
}
}
fn sort_model_rows_for_view(rows: &mut [&ModelPickerRow], view: ModelListView) {
match view {
ModelListView::Configured | ModelListView::Catalog => {}
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 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),
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 profile = resolved_capability_profile_with_overrides(
provider,
id,
CapabilityOverride {
context_window: context_override,
..CapabilityOverride::default()
},
);
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 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,
pricing,
source: card.map(|card| card.source),
}
}
fn render_picker_model_hint(
id: &str,
provider: Option<ApiProvider>,
metadata: &EffectivePickerMetadata,
codex_freshness: Option<CodexModelCacheFreshness>,
) -> String {
if id == "auto" {
return "select per turn".to_string();
}
let mut parts = Vec::new();
if let Some(context_window) = metadata.context_window {
if provider == Some(ApiProvider::OpenaiCodex) {
parts.push(format!(
"{} ctx · ChatGPT route",
format_picker_context_window(context_window)
));
} else {
parts.push(format!(
"{} ctx",
format_picker_context_window(context_window)
));
}
}
if let Some(max_output) = metadata.max_output {
parts.push(format!("{} out", format_picker_context_window(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.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(" · ")
}
}
fn format_picker_context_window(tokens: u32) -> 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 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() => ViewAction::None,
KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
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 => {
let effort = self.resolved_effort();
self.selected_model_idx = 0;
self.select_effort_for_current_model(effort);
}
Pane::Effort => self.selected_effort_idx = 0,
}
ViewAction::None
}
KeyCode::End => {
match self.focus {
Pane::Model => {
let effort = self.resolved_effort();
self.selected_model_idx = self.model_row_count().saturating_sub(1);
self.select_effort_for_current_model(effort);
}
Pane::Effort => {
self.selected_effort_idx = self.current_efforts().len().saturating_sub(1);
}
}
ViewAction::None
}
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
self.toggle_focus();
ViewAction::None
}
_ => 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 => {
let effort = self.resolved_effort();
self.selected_model_idx = idx.min(self.model_row_count().saturating_sub(1));
self.select_effort_for_current_model(effort);
}
Pane::Effort => {
self.selected_effort_idx =
idx.min(self.current_efforts().len().saturating_sub(1));
}
}
self.last_mouse_selected = Some((pane, idx));
if apply && self.selected_model_is_selectable() {
ViewAction::EmitAndClose(self.build_event())
} 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("↑↓", "move"),
ActionHint::new("Tab", "switch"),
ActionHint::new(
tr(self.locale, MessageId::RouteActionType),
tr(self.locale, MessageId::RouteActionSearchAnyModel),
),
ActionHint::new("Enter", "apply"),
ActionHint::new("A", view_action),
ActionHint::new("Esc", "cancel"),
],
);
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_ACCENT_PRIMARY).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 = ListDetailLayout::split(shell[1], 24);
let mut model_rows: Vec<(String, String)> = self
.visible_model_rows()
.iter()
.map(|row| {
(
model_row_label(row, self.initial_provider),
row.hint.clone(),
)
})
.collect();
if let Some((model, provider)) = self.custom_model_row() {
let label = if self.query.trim().is_empty() {
model
} else {
format!("{} · {}", provider.display_name(), model)
};
let hint = if self.query.trim().is_empty() {
"current (custom)".to_string()
} else {
"custom route".to_string()
};
model_rows.push((label, hint));
}
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<(String, String)> = 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::Low => "lighter reasoning".to_string(),
ReasoningEffort::Medium => "balanced reasoning".to_string(),
ReasoningEffort::High => "deeper reasoning".to_string(),
ReasoningEffort::Max => {
if effort_provider == ApiProvider::OpenaiCodex {
"extra-high reasoning".to_string()
} else {
"maximum reasoning".to_string()
}
}
};
(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,
},
);
}
}
fn picker_efforts_for_provider(
provider: ApiProvider,
model_is_auto: bool,
) -> &'static [ReasoningEffort] {
if model_is_auto {
return AUTO_MODEL_PICKER_EFFORTS;
}
match provider {
ApiProvider::OpenaiCodex => CODEX_PICKER_EFFORTS,
_ => DEFAULT_PICKER_EFFORTS,
}
}
fn normalize_picker_effort(
effort: ReasoningEffort,
provider: ApiProvider,
model_is_auto: bool,
) -> ReasoningEffort {
if model_is_auto {
return ReasoningEffort::Auto;
}
if provider == ApiProvider::OpenaiCodex {
return effort.normalize_for_provider(provider);
}
match effort {
ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
other => other,
}
}
fn default_picker_effort_idx(provider: ApiProvider, model_is_auto: bool) -> usize {
let default_effort = if model_is_auto {
ReasoningEffort::Auto
} else if provider == ApiProvider::OpenaiCodex {
ReasoningEffort::Medium
} else {
ReasoningEffort::High
};
picker_efforts_for_provider(provider, model_is_auto)
.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>,
std::sync::MutexGuard<'static, ()>,
),
) {
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 {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: true,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
};
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.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app.provider_models.clear();
(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))
}
#[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 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
.visible_model_rows()
.into_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 view = ModelPickerView::new(&app, &config);
let row = view
.model_rows
.iter()
.find(|row| row.provider == Some(ApiProvider::OpenaiCodex) && row.id == "gpt-5.5")
.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::None
));
}
}
#[test]
fn invalid_candidate_is_visible_but_enter_is_inert() {
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::None
));
}
#[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_are_scoped_to_active_provider() {
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);
assert!(
view.visible_model_rows()
.iter()
.all(|row| row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::Together))
);
assert!(
!view
.visible_model_ids()
.contains(&crate::config::DEFAULT_OPENROUTER_MODEL),
"OpenRouter saved rows must not appear as bare Together model choices"
);
}
#[test]
fn picker_default_view_includes_explicitly_configured_provider_rows() {
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()
.any(|row| row.provider == Some(crate::config::ApiProvider::Together)),
"explicitly configured Together should surface rows by default: {visible_ids:?}"
);
assert!(visible_ids.contains(&crate::config::DEFAULT_TOGETHER_MODEL));
assert!(visible_ids.contains(&"auto"));
assert!(visible_ids.contains(&"deepseek-v4-pro"));
}
#[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()
};
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 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_forces_auto_effort_on_apply() {
let (mut app, config, _lock) = create_test_app();
app.model = "auto".to_string();
app.auto_model = true;
app.reasoning_effort = ReasoningEffort::Off;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.resolved_model(), "auto");
assert_eq!(view.resolved_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_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_provider(crate::config::ApiProvider::Deepseek, false)
.iter()
.map(|effort| effort.as_setting())
.collect();
assert_eq!(effort_labels, vec!["auto", "off", "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_provider(crate::config::ApiProvider::OpenaiCodex, 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_excludes_saved_codex_model_from_deepseek_main_section() {
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()
.all(|row| row.provider.is_none()
|| row.provider == Some(crate::config::ApiProvider::Deepseek))
);
assert!(!view.visible_model_ids().contains(&"gpt-5.5"));
}
#[test]
fn picker_does_not_switch_provider_when_moving_through_model_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::Max;
app.provider_models
.insert("openai-codex".to_string(), "gpt-5.5".to_string());
let mut view = ModelPickerView::new(&app, &config);
while view.move_down() {
assert_ne!(
view.resolved_provider(),
Some(crate::config::ApiProvider::OpenaiCodex)
);
}
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"
);
}
fn cross_provider_row() -> ModelPickerRow {
ModelPickerRow {
id: "z-ai/glm-5.2".to_string(),
provider: Some(ApiProvider::Zai),
hint: "switch route · reasoning".to_string(),
metadata: EffectivePickerMetadata::default(),
selectable: 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),
hint: String::new(),
metadata: EffectivePickerMetadata::default(),
selectable: 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 = view.visible_model_ids();
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 = view.visible_model_ids();
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_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 spans = picker_row_spans(
"minimax/minimax-m3",
"1M multimodal",
"â–¸",
24,
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");
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_excludes_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))
);
}
#[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.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, 2);
view.handle_key(KeyEvent::new(
KeyCode::Down,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(view.selected_effort_idx, 3);
}
#[test]
fn mouse_wheel_moves_focused_picker_pane() {
let (mut app, config, _lock) = create_test_app();
app.model = "deepseek-v4-pro".to_string();
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.model = "deepseek-v4-pro".to_string();
app.auto_model = false;
let mut view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 1);
assert_eq!(view.selected_effort_idx, 2);
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 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;
let view = ModelPickerView::new(&app, &config);
assert_eq!(view.selected_model_idx, 2);
assert_eq!(view.selected_effort_idx, 3);
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, config, _lock) = create_test_app();
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_ACCENT_PRIMARY),
"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, 2);
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_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_auto_off_high_max() {
let labels: Vec<&str> =
picker_efforts_for_provider(crate::config::ApiProvider::Deepseek, false)
.iter()
.map(|effort| effort.short_label())
.collect();
assert_eq!(labels, vec!["auto", "off", "high", "max"]);
}
#[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<(String, String)> = (1..=3)
.map(|n| (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<(String, String)> = (1..=4)
.map(|n| (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:?}"
);
}
}