pub(crate) mod input;
pub(crate) use input::{handle_event, InputOutcome};
use std::collections::{BTreeSet, HashSet};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DevProviderSelection {
key: &'static str,
name: &'static str,
auth_kind: DevProviderAuth,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DevProviderAuth {
OAuth(&'static str),
Cloud,
ApiKey,
LocalModels,
}
fn dev_model_providers() -> Vec<DevProviderSelection> {
let mut providers = vec![
DevProviderSelection {
key: "anthropic",
name: "Anthropic",
auth_kind: DevProviderAuth::OAuth("anthropic"),
},
DevProviderSelection {
key: "openai-codex",
name: "OpenAI Codex",
auth_kind: DevProviderAuth::OAuth("openai-codex"),
},
DevProviderSelection {
key: "xai-auth",
name: "xAI (Grok)",
auth_kind: DevProviderAuth::OAuth("xai-auth"),
},
DevProviderSelection {
key: "github-copilot",
name: "GitHub Copilot",
auth_kind: DevProviderAuth::OAuth("github-copilot"),
},
DevProviderSelection {
key: "google-gemini",
name: "Google Gemini (Code Assist)",
auth_kind: DevProviderAuth::OAuth("google-gemini"),
},
DevProviderSelection {
key: "kimi-code",
name: "Kimi Code",
auth_kind: DevProviderAuth::OAuth("kimi-code"),
},
DevProviderSelection {
key: "azure-openai",
name: "Azure OpenAI",
auth_kind: DevProviderAuth::Cloud,
},
DevProviderSelection {
key: "aws-bedrock",
name: "AWS Bedrock",
auth_kind: DevProviderAuth::Cloud,
},
DevProviderSelection {
key: "google-vertex",
name: "Google Vertex",
auth_kind: DevProviderAuth::Cloud,
},
];
providers.extend(
synaps_cli::runtime::openai::registry::providers()
.iter()
.map(|spec| DevProviderSelection {
key: spec.key,
name: spec.name,
auth_kind: DevProviderAuth::ApiKey,
}),
);
providers.push(DevProviderSelection {
key: "local",
name: "Local",
auth_kind: DevProviderAuth::LocalModels,
});
providers
}
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget},
};
use super::theme::{ModalKind, THEME};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelsView {
All,
Favorites,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ModelEntry {
pub id: String,
pub display_id: String,
pub label: String,
pub tier: String,
pub provider_key: String,
pub provider_name: String,
pub favorite_id: String,
pub configured: bool,
pub is_current: bool,
pub is_favorite: bool,
pub order: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExpandedModelEntry {
pub id: String,
pub label: String,
pub is_favorite: bool,
pub metadata: Vec<String>,
}
impl ExpandedModelEntry {
#[allow(dead_code)]
pub(crate) fn new(id: String, label: String, is_favorite: bool) -> Self {
Self {
id,
label,
is_favorite,
metadata: Vec::new(),
}
}
pub(crate) fn with_metadata(
id: String,
label: String,
is_favorite: bool,
metadata: Vec<String>,
) -> Self {
Self {
id,
label,
is_favorite,
metadata,
}
}
pub(crate) fn metadata_label(&self) -> String {
self.metadata.join(" · ")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FuzzyScore {
gaps: usize,
first_match: usize,
haystack_len: usize,
}
pub(crate) fn fuzzy_model_score(query: &str, haystack: &str) -> Option<FuzzyScore> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(FuzzyScore {
gaps: 0,
first_match: 0,
haystack_len: haystack.chars().count(),
});
}
let haystack_lower = haystack.to_lowercase();
let mut search_start = 0usize;
let mut positions = Vec::new();
for ch in query.chars() {
let tail = &haystack_lower[search_start..];
let found = tail
.char_indices()
.find(|(_, candidate)| *candidate == ch)?;
let pos = search_start + found.0;
positions.push(pos);
search_start = pos + found.1.len_utf8();
}
let first_match = positions.first().copied().unwrap_or(0);
let span = positions
.last()
.copied()
.unwrap_or(first_match)
.saturating_sub(first_match)
+ 1;
let gaps = span.saturating_sub(query.chars().count());
Some(FuzzyScore {
gaps,
first_match,
haystack_len: haystack_lower.len(),
})
}
pub(crate) fn sort_expanded_models(models: &mut [ExpandedModelEntry], query: &str) {
models.sort_by(|a, b| {
let score_a = fuzzy_model_score(query, &format!("{} {}", a.id, a.label));
let score_b = fuzzy_model_score(query, &format!("{} {}", b.id, b.label));
match (score_a, score_b) {
(Some(a_score), Some(b_score)) => a_score
.gaps
.cmp(&b_score.gaps)
.then_with(|| a_score.first_match.cmp(&b_score.first_match))
.then_with(|| a_score.haystack_len.cmp(&b_score.haystack_len))
.then_with(|| a.id.cmp(&b.id)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.id.cmp(&b.id),
}
});
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ModelSection {
pub provider_key: String,
pub provider_name: String,
pub configured: bool,
pub entries: Vec<ModelEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum VisibleRow {
Section { idx: usize },
Model { section: usize, idx: usize },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExpandedLoadState {
Loading,
Ready(Vec<ExpandedModelEntry>),
Error(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ExpandedModelsState {
pub provider_key: String,
pub provider_name: String,
pub cursor: usize,
pub search: String,
pub load_state: ExpandedLoadState,
}
pub(crate) type ProviderCatalogOverride = Vec<(String, String)>;
#[derive(Clone, Debug)]
pub(crate) struct ModelsModalState {
pub cursor: usize,
pub search: String,
pub view: ModelsView,
pub collapsed: HashSet<String>,
pub favorites: BTreeSet<String>,
pub expanded: Option<ExpandedModelsState>,
pub provider_catalog_overrides: std::collections::BTreeMap<String, ProviderCatalogOverride>,
}
impl ModelsModalState {
pub fn new() -> Self {
let favorites: BTreeSet<String> = synaps_cli::config::load_config()
.favorite_models
.into_iter()
.map(|id| normalize_favorite_id(&id))
.collect();
let view = if favorites.is_empty() {
ModelsView::All
} else {
ModelsView::Favorites
};
Self {
cursor: 0,
search: String::new(),
view,
collapsed: HashSet::new(),
favorites,
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
}
}
pub fn refresh_favorites(&mut self) {
self.favorites = synaps_cli::config::load_config()
.favorite_models
.into_iter()
.map(|id| normalize_favorite_id(&id))
.collect();
if self.favorites.is_empty() && self.view == ModelsView::Favorites {
self.view = ModelsView::All;
}
}
}
pub(crate) fn normalize_favorite_id(model: &str) -> String {
if let Some(id) = model.strip_prefix("claude/") {
format!("anthropic/{id}")
} else if model.contains('/') {
model.to_string()
} else if model.starts_with("claude-") {
format!("anthropic/{model}")
} else {
model.to_string()
}
}
#[cfg(test)]
pub(crate) fn model_id_for_runtime(favorite_id: &str) -> String {
normalize_favorite_id(favorite_id)
}
fn remove_favorite_compat(id: &str) {
let canonical = normalize_favorite_id(id);
let _ = synaps_cli::config::remove_favorite_model(&canonical);
if let Some(wire_id) = canonical.strip_prefix("anthropic/") {
let _ = synaps_cli::config::remove_favorite_model(wire_id);
let _ = synaps_cli::config::remove_favorite_model(&format!("claude/{wire_id}"));
}
}
pub(crate) fn auto_refresh_catalog_providers() -> &'static [&'static str] {
&["anthropic"]
}
const SOURCE_CONTROLLED_PROVIDER: &str = "openai-codex";
pub(crate) fn codex_static_expanded_entries() -> Vec<ExpandedModelEntry> {
synaps_cli::runtime::openai::catalog::codex_static_catalog_models()
.into_iter()
.map(|model| {
let mut metadata = vec!["static".to_string()];
if model.codex_supported_levels().is_some() {
metadata.push("thinking".to_string());
}
ExpandedModelEntry::with_metadata(
model.runtime_id(),
model.display_label().to_string(),
false,
metadata,
)
})
.collect()
}
pub(crate) fn canonicalize_model_list_result(
provider_key: &str,
result: Result<Vec<ExpandedModelEntry>, String>,
) -> Result<Vec<ExpandedModelEntry>, String> {
if provider_key == SOURCE_CONTROLLED_PROVIDER {
return Ok(codex_static_expanded_entries());
}
result
}
pub(crate) fn apply_model_list_result(
state: &mut ModelsModalState,
provider_key: &str,
result: Result<Vec<ExpandedModelEntry>, String>,
) {
let result = canonicalize_model_list_result(provider_key, result);
set_expanded_models(
state,
provider_key,
match &result {
Ok(models) => Ok(models.clone()),
Err(err) => Err(err.clone()),
},
);
if let Ok(models) = result {
let override_rows = catalog_override_rows(provider_key, &models);
if !override_rows.is_empty() {
state
.provider_catalog_overrides
.insert(provider_key.to_string(), override_rows);
}
}
}
pub(crate) fn catalog_override_rows(
provider_key: &str,
models: &[ExpandedModelEntry],
) -> ProviderCatalogOverride {
if provider_key == SOURCE_CONTROLLED_PROVIDER {
return Vec::new();
}
let prefix = format!("{provider_key}/");
models
.iter()
.filter_map(|model| {
let bare = model
.id
.strip_prefix(&prefix)
.unwrap_or(model.id.as_str())
.to_string();
if bare.trim().is_empty() {
return None;
}
Some((bare, model.label.clone()))
})
.collect()
}
pub(crate) fn fmt_latency(
status: synaps_cli::runtime::openai::ping::PingStatus,
ms: u64,
) -> String {
use synaps_cli::runtime::openai::ping::PingStatus;
match status {
PingStatus::Online => {
if ms >= 1000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
format!("{}ms", ms)
}
}
other => other.label().to_string(),
}
}
pub(crate) fn picker_rows_from_sections(
sections: &[ModelSection],
model_health: &std::collections::HashMap<
String,
(synaps_cli::runtime::openai::ping::PingStatus, u64),
>,
) -> Vec<(String, String)> {
let mut rows = Vec::new();
for section in sections {
rows.push((format!("── {} ──", section.provider_name), String::new()));
for entry in §ion.entries {
let health = model_health
.get(&entry.id)
.map(|(s, ms)| format!("{} {:>6} ", s.icon(), fmt_latency(*s, *ms)))
.unwrap_or_default();
let tier = if entry.tier.is_empty() {
String::new()
} else {
format!(" [{}]", entry.tier)
};
rows.push((
format!(" {}{} — {}{}", health, entry.id, entry.label, tier),
entry.id.clone(),
));
}
}
rows
}
pub(crate) fn settings_model_picker_rows(
current_model: &str,
catalog_overrides: &std::collections::BTreeMap<String, ProviderCatalogOverride>,
model_health: &std::collections::HashMap<
String,
(synaps_cli::runtime::openai::ping::PingStatus, u64),
>,
) -> Vec<(String, String)> {
let mut state = ModelsModalState::new();
state.view = ModelsView::All;
state.search.clear();
state.provider_catalog_overrides = catalog_overrides.clone();
picker_rows_from_sections(&build_sections(current_model, &state), model_health)
}
pub(crate) fn build_sections(current_model: &str, state: &ModelsModalState) -> Vec<ModelSection> {
let availability = ProviderAvailability {
configured_static: synaps_cli::auth::broker::configured_static_provider_keys(),
local_models: synaps_cli::auth::broker::local_model_ids(),
};
let logged_in_oauth = logged_in_oauth_providers();
build_sections_from_parts(current_model, state, &availability, &logged_in_oauth)
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ProviderAvailability {
pub configured_static: BTreeSet<String>,
pub local_models: Vec<String>,
}
fn logged_in_oauth_providers() -> BTreeSet<&'static str> {
[
synaps_cli::auth::OAuthProviderId::Anthropic,
synaps_cli::auth::OAuthProviderId::OpenAiCodex,
synaps_cli::auth::OAuthProviderId::Xai,
synaps_cli::auth::OAuthProviderId::GitHubCopilot,
synaps_cli::auth::OAuthProviderId::GoogleGemini,
synaps_cli::auth::OAuthProviderId::KimiCode,
]
.into_iter()
.filter(|id| synaps_cli::auth::broker::oauth_provider_logged_in(*id))
.map(|id| id.as_str())
.collect()
}
fn build_sections_from_parts(
current_model: &str,
state: &ModelsModalState,
availability: &ProviderAvailability,
logged_in_oauth: &BTreeSet<&'static str>,
) -> Vec<ModelSection> {
let query = state.search.trim().to_lowercase();
let favorites_only = state.view == ModelsView::Favorites;
let current_fav = normalize_favorite_id(current_model);
let mut sections = Vec::new();
for provider in dev_model_providers() {
if !dev_provider_is_logged_in(&provider, availability, logged_in_oauth) {
continue;
}
let mut entries: Vec<ModelEntry> =
provider_model_selections(&provider, availability, &state.provider_catalog_overrides)
.into_iter()
.map(|model| {
let (runtime_id, favorite_id) = if provider.key == "claude" {
(model.id.clone(), format!("claude/{}", model.id))
} else {
let full_id = format!("{}/{}", provider.key, model.id);
(full_id.clone(), full_id)
};
ModelEntry {
id: runtime_id.clone(),
display_id: model.id.clone(),
label: model.label.clone(),
tier: model.tier.clone(),
provider_key: provider.key.to_string(),
provider_name: provider.name.to_string(),
configured: true,
is_current: current_model == runtime_id || current_fav == favorite_id,
is_favorite: state
.favorites
.iter()
.any(|id| normalize_favorite_id(id) == favorite_id),
favorite_id,
order: model.order,
}
})
.filter(|m| model_matches(m, &query, favorites_only))
.collect();
pin_favorites_first(&mut entries);
if !entries.is_empty() || provider.auth_kind == DevProviderAuth::Cloud {
sections.push(ModelSection {
provider_key: provider.key.to_string(),
provider_name: provider.name.to_string(),
configured: true,
entries,
});
}
}
if let Some(manager) = synaps_cli::runtime::openai::extension_manager_for_routing() {
if let Ok(manager) = manager.try_read() {
for provider in manager.providers() {
let mut entries: Vec<ModelEntry> = provider
.spec
.models
.iter()
.enumerate()
.map(|(order, model)| {
let runtime_id =
synaps_cli::extensions::providers::ProviderRegistry::model_runtime_id(
&provider.plugin_id,
&provider.provider_id,
&model.id,
);
let mut badges: Vec<&str> = Vec::new();
if model
.capabilities
.get("tool_use")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
badges.push("tool-use");
}
if model
.capabilities
.get("streaming")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
badges.push("streaming");
}
let base = model
.display_name
.clone()
.unwrap_or_else(|| model.id.clone());
let label = if badges.is_empty() {
base
} else {
let suffix = badges
.iter()
.map(|b| format!("[{}]", b))
.collect::<Vec<_>>()
.join(" ");
format!("{} {}", base, suffix)
};
ModelEntry {
id: runtime_id.clone(),
display_id: model.id.clone(),
label,
tier: "extension".to_string(),
provider_key: provider.runtime_id.clone(),
provider_name: provider.spec.display_name.clone(),
configured: true,
is_current: current_model == runtime_id,
is_favorite: state.favorites.contains(&runtime_id),
favorite_id: runtime_id,
order,
}
})
.filter(|m| model_matches(m, &query, favorites_only))
.collect();
pin_favorites_first(&mut entries);
if !entries.is_empty() {
sections.push(ModelSection {
provider_key: provider.runtime_id.clone(),
provider_name: provider.spec.display_name.clone(),
configured: true,
entries,
});
}
}
}
}
sections
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ModelSelectionItem {
id: String,
label: String,
tier: String,
order: usize,
}
fn provider_model_selections(
provider: &DevProviderSelection,
availability: &ProviderAvailability,
catalog_overrides: &std::collections::BTreeMap<String, ProviderCatalogOverride>,
) -> Vec<ModelSelectionItem> {
if provider.auth_kind == DevProviderAuth::LocalModels {
return availability
.local_models
.clone()
.into_iter()
.enumerate()
.map(|(order, id)| ModelSelectionItem {
label: local_model_label(&id),
id,
tier: "local".to_string(),
order,
})
.collect();
}
if provider.key != "openai-codex" {
if let Some(live) = catalog_overrides.get(provider.key) {
if !live.is_empty() {
return live
.iter()
.enumerate()
.map(|(order, (id, label))| ModelSelectionItem {
id: id.clone(),
label: label.clone(),
tier: provider_model_tier(provider.key, id).to_string(),
order,
})
.collect();
}
}
}
provider_static_model_seeds(provider)
.into_iter()
.enumerate()
.map(|(order, (id, label, tier))| ModelSelectionItem {
id,
label,
tier,
order,
})
.collect()
}
fn provider_model_tier(provider_key: &str, model_id: &str) -> &'static str {
match (provider_key, model_id) {
("openai-codex", "gpt-5.6-sol") => "S+",
("openai-codex", "gpt-5.6-terra" | "gpt-5.5") => "A",
("openai-codex", "gpt-5.6-luna") => "B+",
_ => "",
}
}
fn provider_static_model_seeds(provider: &DevProviderSelection) -> Vec<(String, String, String)> {
match provider.key {
"anthropic" => synaps_cli::models::KNOWN_MODELS
.iter()
.enumerate()
.map(|(idx, (id, label))| {
let tier = match idx {
0 => "S+",
1 | 2 => "S",
_ => "A",
};
((*id).to_string(), (*label).to_string(), tier.to_string())
})
.collect(),
"openai-codex" => synaps_cli::runtime::openai::catalog::codex_static_catalog_models()
.into_iter()
.map(|model| {
let tier = provider_model_tier(provider.key, &model.id);
(model.id, model.label.unwrap_or_default(), tier.to_string())
})
.collect(),
"xai-auth" => synaps_cli::runtime::openai::catalog::xai_static_catalog_models()
.into_iter()
.map(|model| (model.id, model.label.unwrap_or_default(), String::new()))
.collect(),
"github-copilot" => synaps_cli::runtime::openai::catalog::copilot_static_catalog_models()
.into_iter()
.map(|model| {
let tier = match model.id.as_str() {
"gpt-5.3-codex" | "gpt-5.6-luna" | "gpt-5.6-terra" => "S+",
"gpt-5.4" | "gpt-5.4-mini" | "claude-sonnet-4.6" | "claude-sonnet-5"
| "claude-haiku-4.5" => "S",
_ => "",
};
(model.id, model.label.unwrap_or_default(), tier.to_string())
})
.collect(),
"google-gemini" => {
synaps_cli::runtime::openai::catalog::google_gemini_static_catalog_models()
.into_iter()
.map(|model| {
let tier = match model.id.as_str() {
"gemini-2.5-pro" => "S",
"gemini-2.5-flash" => "A",
_ => "",
};
(model.id, model.label.unwrap_or_default(), tier.to_string())
})
.collect()
}
"kimi-code" => synaps_cli::runtime::openai::catalog::kimi_code_static_catalog_models()
.into_iter()
.map(|model| {
let tier = match model.id.as_str() {
"k3" | "k3-256k" => "S+",
"kimi-for-coding" => "S",
_ => "",
};
(model.id, model.label.unwrap_or_default(), tier.to_string())
})
.collect(),
key => synaps_cli::runtime::openai::registry::providers()
.iter()
.find(|spec| spec.key == key)
.map(|spec| {
spec.models
.iter()
.map(|(id, label, tier)| {
((*id).to_string(), (*label).to_string(), (*tier).to_string())
})
.collect()
})
.unwrap_or_default(),
}
}
fn local_model_label(id: &str) -> String {
id.split(':')
.next()
.unwrap_or(id)
.replace('-', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn dev_provider_is_logged_in(
provider: &DevProviderSelection,
availability: &ProviderAvailability,
logged_in_oauth: &BTreeSet<&'static str>,
) -> bool {
match provider.auth_kind {
DevProviderAuth::LocalModels => !availability.local_models.is_empty(),
DevProviderAuth::Cloud => true,
DevProviderAuth::ApiKey => availability.configured_static.contains(provider.key),
DevProviderAuth::OAuth(storage_key) => logged_in_oauth.contains(storage_key),
}
}
fn model_matches(model: &ModelEntry, query: &str, favorites_only: bool) -> bool {
if favorites_only && !model.is_favorite {
return false;
}
if query.is_empty() {
return true;
}
let haystack = format!(
"{} {} {} {}",
model.id, model.display_id, model.label, model.provider_name
)
.to_lowercase();
haystack.contains(query)
}
fn pin_favorites_first(entries: &mut [ModelEntry]) {
entries.sort_by(|a, b| {
b.is_favorite
.cmp(&a.is_favorite)
.then_with(|| a.order.cmp(&b.order))
.then_with(|| a.display_id.cmp(&b.display_id))
});
}
pub(crate) fn visible_rows(sections: &[ModelSection], state: &ModelsModalState) -> Vec<VisibleRow> {
let mut rows = Vec::new();
for (section_idx, section) in sections.iter().enumerate() {
rows.push(VisibleRow::Section { idx: section_idx });
if !state.collapsed.contains(§ion.provider_key) {
for (idx, _) in section.entries.iter().enumerate() {
rows.push(VisibleRow::Model {
section: section_idx,
idx,
});
}
}
}
rows
}
pub(crate) fn selected_provider<'a>(
sections: &'a [ModelSection],
state: &ModelsModalState,
) -> Option<&'a ModelSection> {
let rows = visible_rows(sections, state);
match rows.get(state.cursor)? {
VisibleRow::Section { idx } => sections.get(*idx),
VisibleRow::Model { section, .. } => sections.get(*section),
}
}
pub(crate) fn expanded_visible_models(state: &ModelsModalState) -> Vec<ExpandedModelEntry> {
let Some(expanded) = state.expanded.as_ref() else {
return Vec::new();
};
let ExpandedLoadState::Ready(models) = &expanded.load_state else {
return Vec::new();
};
let mut visible: Vec<_> = models
.iter()
.filter(|model| {
fuzzy_model_score(&expanded.search, &format!("{} {}", model.id, model.label)).is_some()
})
.cloned()
.collect();
sort_expanded_models(&mut visible, &expanded.search);
visible
}
pub(crate) fn selected_expanded_model(state: &ModelsModalState) -> Option<ExpandedModelEntry> {
let expanded = state.expanded.as_ref()?;
expanded_visible_models(state).get(expanded.cursor).cloned()
}
pub(crate) fn set_expanded_models(
state: &mut ModelsModalState,
provider_key: &str,
result: Result<Vec<ExpandedModelEntry>, String>,
) {
let Some(expanded) = state.expanded.as_mut() else {
return;
};
if expanded.provider_key != provider_key {
return;
}
expanded.cursor = 0;
expanded.load_state = match result {
Ok(mut models) => {
for model in &mut models {
model.is_favorite = state.favorites.contains(&model.id);
}
ExpandedLoadState::Ready(models)
}
Err(err) => ExpandedLoadState::Error(err),
};
}
pub(crate) fn selected_model<'a>(
sections: &'a [ModelSection],
state: &ModelsModalState,
) -> Option<&'a ModelEntry> {
let rows = visible_rows(sections, state);
match rows.get(state.cursor)? {
VisibleRow::Model { section, idx } => sections.get(*section)?.entries.get(*idx),
VisibleRow::Section { .. } => None,
}
}
pub(crate) fn render(
frame: &mut ratatui::Frame<'_>,
area: Rect,
state: &ModelsModalState,
current_model: &str,
) {
let sections = build_sections(current_model, state);
frame.render_widget(Clear, area);
frame.render_widget(
ModelsModalWidget {
state,
sections,
current_model,
},
area,
);
}
struct ModelsModalWidget<'a> {
state: &'a ModelsModalState,
sections: Vec<ModelSection>,
current_model: &'a str,
}
impl Widget for ModelsModalWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let theme = THEME.load();
let mut block = Block::default()
.title(" Models ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.modal_border(ModalKind::Models)))
.padding(Padding {
left: 2,
right: 2,
top: 1,
bottom: 1,
});
if let Some(tc) = theme.modal_title(ModalKind::Models) {
block = block.title_style(Style::default().fg(tc));
}
let inner = block.inner(area);
block.render(area, buf);
let rows = visible_rows(&self.sections, self.state);
let model_count: usize = self.sections.iter().map(|s| s.entries.len()).sum();
let favorite_count = self.state.favorites.len();
let view = match self.state.view {
ModelsView::All => "[✦ All] ★ Favorites",
ModelsView::Favorites => "✦ All [★ Favorites]",
};
let [title, search_bar, view_bar, list_area, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(inner);
Paragraph::new(Line::from(vec![
Span::styled(
"◇ Switch model",
Style::default()
.fg(theme.header_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" · {model_count} available · {favorite_count} ★ favorites"),
Style::default().fg(theme.muted),
),
]))
.render(title, buf);
Paragraph::new(Line::from(vec![
Span::styled("Search: ", Style::default().fg(theme.muted)),
Span::styled(
if self.state.search.is_empty() {
"▎".to_string()
} else {
format!("{}▎", self.state.search)
},
Style::default().fg(theme.header_fg),
),
]))
.render(search_bar, buf);
Paragraph::new(Line::from(vec![
Span::styled("View: ", Style::default().fg(theme.muted)),
Span::styled(view, Style::default().fg(theme.help_fg)),
Span::styled(
format!(" current: {}", self.current_model),
Style::default().fg(theme.muted),
),
]))
.render(view_bar, buf);
let list_height = list_area.height as usize;
let offset = self
.state
.cursor
.saturating_sub(list_height.saturating_sub(1));
let lines: Vec<Line> = rows
.iter()
.enumerate()
.skip(offset)
.take(list_height)
.map(|(row_idx, row)| match row {
VisibleRow::Section { idx } => {
let section = &self.sections[*idx];
let selected = row_idx == self.state.cursor;
let glyph = if self.state.collapsed.contains(§ion.provider_key) {
"▸"
} else {
"▾"
};
let style = if selected {
Style::default()
.fg(theme.header_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.help_fg)
};
Line::from(vec![
Span::styled(
if selected { "● " } else { " " },
Style::default().fg(theme.border_active),
),
Span::styled(format!("{glyph} {}", section.provider_name), style),
Span::styled(
format!(" ({})", section.entries.len()),
Style::default().fg(theme.muted),
),
])
}
VisibleRow::Model { section, idx } => {
let entry = &self.sections[*section].entries[*idx];
let selected = row_idx == self.state.cursor;
let marker = if selected { "●" } else { "○" };
let fav = if entry.is_favorite { "★" } else { " " };
let current = if entry.is_current { " default" } else { "" };
let tier = if entry.tier.is_empty() {
String::new()
} else {
format!(" {}", entry.tier)
};
let style = if selected {
Style::default()
.fg(theme.header_fg)
.add_modifier(Modifier::BOLD)
} else if entry.is_current {
Style::default().fg(theme.status_ready)
} else {
Style::default().fg(theme.input_fg)
};
Line::from(vec![
Span::styled(
format!(" {marker} "),
Style::default().fg(theme.border_active),
),
Span::styled(format!("{:<38}", entry.id), style),
Span::styled(
format!(" — {}", entry.label),
Style::default().fg(theme.muted),
),
Span::styled(
format!(" {fav}"),
Style::default().fg(theme.status_streaming),
),
Span::styled(tier, Style::default().fg(theme.muted)),
Span::styled(current, Style::default().fg(theme.status_ready)),
])
}
})
.collect();
let empty_line = if self.state.view == ModelsView::Favorites {
"No favorite models yet — press Tab for All, then f to favorite."
} else {
"No configured models match the current search."
};
let list = if lines.is_empty() {
vec![Line::from(Span::styled(
empty_line,
Style::default().fg(theme.muted),
))]
} else {
lines
};
Paragraph::new(list).render(list_area, buf);
Paragraph::new(
"↑/↓ select • Enter use • f favorite • e expand • Tab view • c collapse • Esc close",
)
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center)
.render(footer, buf);
if let Some(expanded) = self.state.expanded.as_ref() {
render_expanded_lightbox(area, buf, self.state, expanded);
}
}
}
fn render_expanded_lightbox(
area: Rect,
buf: &mut Buffer,
state: &ModelsModalState,
expanded: &ExpandedModelsState,
) {
let theme = THEME.load();
let width = area.width.saturating_sub(10).min(110);
let height = area.height.saturating_sub(6).min(28);
if width < 20 || height < 6 {
return;
}
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let popup = Rect {
x,
y,
width,
height,
};
Clear.render(popup, buf);
let status = match &expanded.load_state {
ExpandedLoadState::Loading => "loading…".to_string(),
ExpandedLoadState::Ready(models) => format!("{} loaded", models.len()),
ExpandedLoadState::Error(_) => "error".to_string(),
};
let block = Block::default()
.title(format!(" {} models · {} ", expanded.provider_name, status))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_active))
.padding(Padding {
left: 2,
right: 2,
top: 1,
bottom: 1,
});
let inner = block.inner(popup);
block.render(popup, buf);
let [search_bar, list, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(inner);
Paragraph::new(Line::from(vec![
Span::styled("Search: ", Style::default().fg(theme.muted)),
Span::styled(
if expanded.search.is_empty() {
"▎".to_string()
} else {
format!("{}▎", expanded.search)
},
Style::default().fg(theme.header_fg),
),
]))
.render(search_bar, buf);
let list_height = list.height as usize;
let visible = expanded_visible_models(state);
let offset = expanded
.cursor
.saturating_sub(list_height.saturating_sub(1));
let lines = match &expanded.load_state {
ExpandedLoadState::Loading => vec![Line::from(Span::styled(
"Loading provider models…",
Style::default().fg(theme.muted),
))],
ExpandedLoadState::Error(err) => vec![Line::from(Span::styled(
format!("Failed to load models: {err}"),
Style::default().fg(theme.error_color),
))],
ExpandedLoadState::Ready(_) if visible.is_empty() => vec![Line::from(Span::styled(
"No models match the current search.",
Style::default().fg(theme.muted),
))],
ExpandedLoadState::Ready(_) => visible
.iter()
.enumerate()
.skip(offset)
.take(list_height)
.map(|(idx, model)| {
let selected = idx == expanded.cursor;
let marker = if selected { "●" } else { "○" };
let fav = if model.is_favorite { "★" } else { " " };
let style = if selected {
Style::default()
.fg(theme.header_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.input_fg)
};
let meta = model.metadata_label();
let meta_text = if meta.is_empty() {
String::new()
} else {
format!(" · {meta}")
};
Line::from(vec![
Span::styled(
format!("{marker} "),
Style::default().fg(theme.border_active),
),
Span::styled(format!("{:<48}", model.id), style),
Span::styled(
format!(" — {}{}", model.label, meta_text),
Style::default().fg(theme.muted),
),
Span::styled(
format!(" {fav}"),
Style::default().fg(theme.status_streaming),
),
])
})
.collect(),
};
Paragraph::new(lines).render(list, buf);
Paragraph::new("type to fuzzy-search • ↑/↓ select • Enter use • f favorite • Esc back")
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center)
.render(footer, buf);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picker_rows_from_sections_yields_headers_and_exact_ids() {
let sections = vec![ModelSection {
provider_key: "openai-codex".to_string(),
provider_name: "OpenAI Codex".to_string(),
configured: true,
entries: vec![ModelEntry {
id: "openai-codex/gpt-5.6-sol".to_string(),
display_id: "gpt-5.6-sol".to_string(),
label: "Sol".to_string(),
tier: "S+".to_string(),
provider_key: "openai-codex".to_string(),
provider_name: "OpenAI Codex".to_string(),
favorite_id: "openai-codex/gpt-5.6-sol".to_string(),
configured: true,
is_current: false,
is_favorite: false,
order: 0,
}],
}];
let rows = picker_rows_from_sections(§ions, &Default::default());
assert_eq!(rows[0].0, "── OpenAI Codex ──");
assert!(rows[0].1.is_empty(), "header row carries no value");
assert!(
rows[1].0.contains("gpt-5.6-sol") && rows[1].0.contains("Sol"),
"model row display shows id and label: {}",
rows[1].0
);
assert_eq!(rows[1].1, "openai-codex/gpt-5.6-sol");
}
#[test]
fn picker_rows_include_live_catalog_overrides() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::from([(
"anthropic".to_string(),
vec![("claude-live-1".to_string(), "Live One".to_string())],
)]),
};
let sections = build_sections_from_parts(
"m",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic"]),
);
let rows = picker_rows_from_sections(§ions, &Default::default());
assert!(
rows.iter().any(|(_, v)| v == "anthropic/claude-live-1"),
"live catalog override rows must appear with exact runtime ids: {rows:?}"
);
assert!(
!rows
.iter()
.any(|(_, v)| v.starts_with("anthropic/") && v != "anthropic/claude-live-1"),
"static seeds are replaced by live overrides: {rows:?}"
);
}
#[test]
fn picker_rows_health_prefix_never_leaks_into_value() {
let sections = vec![ModelSection {
provider_key: "groq".to_string(),
provider_name: "Groq".to_string(),
configured: true,
entries: vec![ModelEntry {
id: "groq/llama-3.3-70b-versatile".to_string(),
display_id: "llama-3.3-70b-versatile".to_string(),
label: "Llama".to_string(),
tier: "A".to_string(),
provider_key: "groq".to_string(),
provider_name: "Groq".to_string(),
favorite_id: "groq/llama-3.3-70b-versatile".to_string(),
configured: true,
is_current: false,
is_favorite: false,
order: 0,
}],
}];
let health = std::collections::HashMap::from([(
"groq/llama-3.3-70b-versatile".to_string(),
(synaps_cli::runtime::openai::ping::PingStatus::Online, 79u64),
)]);
let rows = picker_rows_from_sections(§ions, &health);
assert!(rows[1].0.contains("79ms"), "display: {}", rows[1].0);
assert_eq!(rows[1].1, "groq/llama-3.3-70b-versatile");
}
#[test]
fn expanded_entry_metadata_label_joins_badges() {
let entry = ExpandedModelEntry::with_metadata(
"openrouter/deepseek/deepseek-r1".to_string(),
"DeepSeek R1".to_string(),
false,
vec!["64K ctx".to_string(), "thinking".to_string()],
);
assert_eq!(entry.metadata_label(), "64K ctx · thinking");
}
#[test]
fn expanded_entry_new_has_no_metadata() {
let entry = ExpandedModelEntry::new("model".to_string(), "Model".to_string(), false);
assert!(entry.metadata.is_empty());
assert_eq!(entry.metadata_label(), "");
}
#[test]
fn anthropic_favorites_normalize_to_canonical_provider_identity() {
assert_eq!(
normalize_favorite_id("claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
normalize_favorite_id("claude/claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
normalize_favorite_id("anthropic/claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
model_id_for_runtime("anthropic/claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
model_id_for_runtime("github-copilot/claude-opus-4-7"),
"github-copilot/claude-opus-4-7"
);
}
#[test]
fn legacy_and_canonical_anthropic_favorites_and_current_model_match() {
for favorite in [
"claude-opus-4-7",
"claude/claude-opus-4-7",
"anthropic/claude-opus-4-7",
] {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::Favorites,
collapsed: HashSet::new(),
favorites: BTreeSet::from([favorite.to_string()]),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
for current in ["claude-opus-4-7", "anthropic/claude-opus-4-7"] {
let sections = build_sections_from_parts(
current,
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic"]),
);
let entry = sections
.iter()
.flat_map(|s| &s.entries)
.find(|m| m.display_id == "claude-opus-4-7")
.expect("legacy favorite remains visible");
assert_eq!(entry.id, "anthropic/claude-opus-4-7");
assert_eq!(entry.favorite_id, "anthropic/claude-opus-4-7");
assert!(entry.is_favorite);
assert!(entry.is_current);
}
}
}
#[test]
fn claude_favorite_ids_round_trip_to_runtime_ids() {
assert_eq!(
normalize_favorite_id("claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
model_id_for_runtime("claude/claude-opus-4-7"),
"anthropic/claude-opus-4-7"
);
assert_eq!(
model_id_for_runtime("groq/llama-3.3-70b-versatile"),
"groq/llama-3.3-70b-versatile"
);
}
#[test]
fn favorites_view_only_keeps_favorite_entries() {
let mut state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::Favorites,
collapsed: HashSet::new(),
favorites: BTreeSet::from(["claude/claude-opus-4-7".to_string()]),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"claude-opus-4-7",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic"]),
);
let total: usize = sections.iter().map(|s| s.entries.len()).sum();
assert_eq!(total, 1);
assert_eq!(
sections[0].entries[0].favorite_id,
"anthropic/claude-opus-4-7"
);
state.view = ModelsView::All;
let sections = build_sections_from_parts(
"claude-opus-4-7",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic"]),
);
assert!(sections.iter().map(|s| s.entries.len()).sum::<usize>() > 1);
}
#[test]
fn unconfigured_openai_providers_are_hidden() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"claude-opus-4-7",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic"]),
);
assert!(sections.iter().any(|s| s.provider_key == "anthropic"));
assert!(!sections.iter().any(|s| s.provider_key == "groq"));
assert!(!sections.iter().any(|s| s.provider_key == "openrouter"));
}
#[test]
fn fuzzy_model_matches_are_case_insensitive_subsequences() {
assert!(fuzzy_model_score("qwc", "Qwen3 Coder 480B").is_some());
assert!(fuzzy_model_score("xyz", "Qwen3 Coder 480B").is_none());
}
#[test]
fn fuzzy_model_matches_rank_contiguous_and_early_matches_first() {
let mut models = vec![
ExpandedModelEntry::new(
"provider/alpha-qwen-coder".to_string(),
"alpha qwen coder".to_string(),
false,
),
ExpandedModelEntry::new(
"provider/qwen3-coder".to_string(),
"qwen3 coder".to_string(),
false,
),
ExpandedModelEntry::new(
"provider/my-q-w-e-n".to_string(),
"my q w e n".to_string(),
false,
),
];
sort_expanded_models(&mut models, "qwen");
assert_eq!(models[0].id, "provider/qwen3-coder");
assert_eq!(models[2].id, "provider/my-q-w-e-n");
}
#[test]
fn local_provider_is_hidden_when_no_local_models_are_configured() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"openai-codex/gpt-5.5",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["openai-codex"]),
);
assert!(!sections.iter().any(|s| s.provider_key == "local"));
}
#[test]
fn local_provider_uses_explicit_local_models_config() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let availability = ProviderAvailability {
configured_static: BTreeSet::new(),
local_models: vec![
"qwen3-coder:latest".to_string(),
"devstral:latest".to_string(),
],
};
let sections = build_sections_from_parts(
"local/qwen3-coder:latest",
&state,
&availability,
&BTreeSet::new(),
);
let local = sections
.iter()
.find(|s| s.provider_key == "local")
.expect("local section");
let ids: Vec<_> = local
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect();
assert_eq!(
ids,
vec!["local/qwen3-coder:latest", "local/devstral:latest"]
);
}
#[test]
fn dev_selected_models_populate_for_logged_in_providers() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let availability = ProviderAvailability {
configured_static: BTreeSet::from(["openrouter".to_string()]),
local_models: Vec::new(),
};
let sections = build_sections_from_parts(
"openai-codex/gpt-5.5",
&state,
&availability,
&BTreeSet::from(["anthropic", "openai-codex"]),
);
assert!(sections.iter().any(|s| s.provider_key == "anthropic"));
assert!(sections.iter().any(|s| s.provider_key == "openai-codex"));
assert!(sections.iter().any(|s| s.provider_key == "openrouter"));
assert!(!sections.iter().any(|s| s.provider_key == "groq"));
let all_ids: Vec<_> = sections
.iter()
.flat_map(|s| s.entries.iter().map(|entry| entry.id.as_str()))
.collect();
assert!(all_ids.contains(&"openrouter/qwen/qwen3-coder"));
assert!(all_ids.contains(&"openrouter/google/gemma-3-27b-it"));
}
#[test]
fn openai_codex_static_models_have_exact_tier_labels() {
let provider = dev_model_providers()
.into_iter()
.find(|provider| provider.key == "openai-codex")
.expect("OpenAI Codex provider");
let seeds = provider_static_model_seeds(&provider);
let tier = |id: &str| {
seeds
.iter()
.find(|(model_id, _, _)| model_id == id)
.map(|(_, _, tier)| tier.as_str())
};
assert_eq!(tier("gpt-5.6-sol"), Some("S+"));
assert_eq!(tier("gpt-5.6-terra"), Some("A"));
assert_eq!(tier("gpt-5.6-luna"), Some("B+"));
assert_eq!(tier("gpt-5.5"), Some("A"));
}
#[test]
fn openai_codex_tier_labels_stay_exact_even_when_override_is_injected() {
let provider = dev_model_providers()
.into_iter()
.find(|provider| provider.key == "openai-codex")
.expect("OpenAI Codex provider");
let live = vec![("gpt-live-1".to_string(), "Live One".to_string())];
let overrides = std::collections::BTreeMap::from([("openai-codex".to_string(), live)]);
let selections =
provider_model_selections(&provider, &ProviderAvailability::default(), &overrides);
assert!(
!selections.iter().any(|model| model.id == "gpt-live-1"),
"injected override rows must not appear"
);
let tier = |id: &str| {
selections
.iter()
.find(|model| model.id == id)
.map(|model| model.tier.as_str())
};
assert_eq!(tier("gpt-5.6-sol"), Some("S+"));
assert_eq!(tier("gpt-5.6-terra"), Some("A"));
assert_eq!(tier("gpt-5.6-luna"), Some("B+"));
assert_eq!(tier("gpt-5.5"), Some("A"));
}
#[test]
fn logged_in_xai_provider_shows_authoritative_static_catalog_immediately() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"xai-auth/grok-4.3",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["xai-auth"]),
);
let xai = sections
.iter()
.find(|section| section.provider_key == "xai-auth")
.expect("logged-in xAI section");
let ids: Vec<_> = xai
.entries
.iter()
.map(|entry| entry.display_id.as_str())
.collect();
let catalog_ids: Vec<_> = synaps_cli::runtime::openai::catalog::xai_static_catalog_models()
.into_iter()
.map(|model| model.id)
.collect();
assert_eq!(ids, catalog_ids);
assert!(xai
.entries
.iter()
.all(|entry| entry.id.starts_with("xai-auth/")));
}
#[test]
fn logged_in_github_copilot_provider_shows_curated_static_catalog_immediately() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"github-copilot/gpt-5.3-codex",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["github-copilot"]),
);
let copilot = sections
.iter()
.find(|section| section.provider_key == "github-copilot")
.expect("logged-in GitHub Copilot section");
let ids: Vec<_> = copilot
.entries
.iter()
.map(|entry| entry.display_id.as_str())
.collect();
let catalog_ids: Vec<_> =
synaps_cli::runtime::openai::catalog::copilot_static_catalog_models()
.into_iter()
.map(|model| model.id)
.collect();
assert_eq!(ids, catalog_ids);
assert!(ids.contains(&"claude-fable-5"));
assert!(ids.contains(&"claude-opus-4.8"));
assert!(copilot
.entries
.iter()
.all(|entry| entry.id.starts_with("github-copilot/")));
assert!(!ids.contains(&"gpt-5.6-sol"));
assert!(!ids.contains(&"auto"));
}
#[test]
fn google_gemini_section_lists_conservative_catalog_when_logged_in() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"google-gemini/gemini-2.5-pro",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["google-gemini"]),
);
let gemini = sections
.iter()
.find(|section| section.provider_key == "google-gemini")
.expect("logged-in Google Gemini section");
let ids: Vec<_> = gemini
.entries
.iter()
.map(|entry| entry.display_id.as_str())
.collect();
assert_eq!(
ids,
vec![
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.5-flash",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-2.5-flash",
]
);
assert!(gemini
.entries
.iter()
.all(|entry| entry.id.starts_with("google-gemini/")));
for banned in ["auto-gemini-2.5", "auto-gemini-3", "text-embedding-004"] {
assert!(!ids.contains(&banned));
}
}
#[test]
fn google_gemini_section_hidden_when_not_logged_in() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let sections = build_sections_from_parts(
"claude/sonnet-4.5",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["claude"]),
);
assert!(sections
.iter()
.all(|section| section.provider_key != "google-gemini"));
}
#[test]
fn configured_registry_provider_uses_all_registry_static_seeds() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
let availability = ProviderAvailability {
configured_static: BTreeSet::from(["nvidia".to_string()]),
local_models: Vec::new(),
};
let sections = build_sections_from_parts(
"nvidia/meta/llama-3.3-70b-instruct",
&state,
&availability,
&BTreeSet::new(),
);
let nvidia = sections
.iter()
.find(|s| s.provider_key == "nvidia")
.expect("nvidia section");
let registry_count = synaps_cli::runtime::openai::registry::providers()
.iter()
.find(|provider| provider.key == "nvidia")
.expect("nvidia registry provider")
.models
.len();
assert_eq!(nvidia.entries.len(), registry_count);
assert!(nvidia
.entries
.iter()
.any(|entry| entry.id == "nvidia/stepfun-ai/step-3.5-flash"));
}
#[test]
fn live_catalog_override_replaces_stale_seeds_in_main_sections_with_provider_qualified_ids() {
let mut state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
apply_model_list_result(
&mut state,
"openai-codex",
Ok(vec![
ExpandedModelEntry::new(
"openai-codex/gpt-5.3-codex".into(),
"GPT-5.3 Codex".into(),
false,
),
ExpandedModelEntry::new("openai-codex/o3".into(), "o3".into(), false),
]),
);
apply_model_list_result(
&mut state,
"anthropic",
Ok(vec![ExpandedModelEntry::new(
"anthropic/claude-sonnet-4-7".into(),
"Sonnet 4.7".into(),
false,
)]),
);
let sections = build_sections_from_parts(
"openai-codex/gpt-5.5",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["anthropic", "openai-codex"]),
);
let codex = sections
.iter()
.find(|s| s.provider_key == "openai-codex")
.expect("codex section");
let codex_ids: Vec<_> = codex.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(
codex_ids,
vec![
"openai-codex/gpt-6-astra",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/gpt-5.3-codex-spark",
]
);
assert!(!codex_ids.contains(&"openai-codex/o3"));
let anthropic = sections
.iter()
.find(|s| s.provider_key == "anthropic")
.expect("anthropic section");
let anthropic_ids: Vec<_> = anthropic.entries.iter().map(|e| e.id.as_str()).collect();
assert_eq!(anthropic_ids, vec!["anthropic/claude-sonnet-4-7"]);
assert!(anthropic
.entries
.iter()
.all(|e| e.id.starts_with("anthropic/") && e.favorite_id.starts_with("anthropic/")));
}
#[test]
fn failed_live_catalog_keeps_static_main_section_seeds() {
let mut state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
apply_model_list_result(
&mut state,
"openai-codex",
Err("model list failed: HTTP 500".into()),
);
assert!(state.provider_catalog_overrides.is_empty());
let sections = build_sections_from_parts(
"openai-codex/gpt-5.5",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["openai-codex"]),
);
let codex = sections
.iter()
.find(|s| s.provider_key == "openai-codex")
.expect("codex section");
assert!(codex.entries.iter().any(|e| e.id == "openai-codex/gpt-5.5"));
}
#[test]
fn apply_model_list_result_also_updates_matching_expanded_state() {
let mut state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: Some(ExpandedModelsState {
provider_key: "anthropic".into(),
provider_name: "Anthropic".into(),
cursor: 0,
search: String::new(),
load_state: ExpandedLoadState::Loading,
}),
provider_catalog_overrides: std::collections::BTreeMap::new(),
};
apply_model_list_result(
&mut state,
"anthropic",
Ok(vec![ExpandedModelEntry::new(
"anthropic/claude-haiku-4-5".into(),
"Haiku".into(),
false,
)]),
);
let expanded = state.expanded.as_ref().expect("expanded");
match &expanded.load_state {
ExpandedLoadState::Ready(models) => {
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "anthropic/claude-haiku-4-5");
}
other => panic!("expected Ready, got {other:?}"),
}
assert!(state.provider_catalog_overrides.contains_key("anthropic"));
}
#[test]
fn auto_refresh_excludes_openai_codex_but_retains_anthropic() {
let providers = auto_refresh_catalog_providers();
assert!(
providers.contains(&"anthropic"),
"anthropic live discovery must be preserved: {providers:?}"
);
assert!(
!providers.contains(&"openai-codex"),
"openai-codex picker rows are hardcoded — no auto catalog refresh: {providers:?}"
);
}
#[test]
fn injected_openai_codex_override_cannot_replace_static_picker_rows() {
let state = ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: None,
provider_catalog_overrides: std::collections::BTreeMap::from([(
"openai-codex".to_string(),
vec![("gpt-live-1".to_string(), "Live One".to_string())],
)]),
};
let sections = build_sections_from_parts(
"openai-codex/gpt-5.5",
&state,
&ProviderAvailability::default(),
&BTreeSet::from(["openai-codex"]),
);
let codex = sections
.iter()
.find(|s| s.provider_key == "openai-codex")
.expect("codex section");
assert!(
!codex.entries.iter().any(|e| e.id.contains("gpt-live-1")),
"injected override must not surface in /models sections"
);
let rows = picker_rows_from_sections(§ions, &Default::default());
assert!(
!rows.iter().any(|(_, v)| v == "openai-codex/gpt-live-1"),
"injected override must not surface in /settings picker rows: {rows:?}"
);
assert!(
rows.iter().any(|(_, v)| v == "openai-codex/gpt-5.6-sol"),
"static OAuth slugs must remain: {rows:?}"
);
}
#[test]
fn openai_codex_picker_rows_are_exactly_the_eight_static_oauth_slugs() {
let provider = dev_model_providers()
.into_iter()
.find(|provider| provider.key == "openai-codex")
.expect("OpenAI Codex provider");
let overrides = std::collections::BTreeMap::from([(
"openai-codex".to_string(),
vec![("gpt-live-1".to_string(), "Live One".to_string())],
)]);
let selections =
provider_model_selections(&provider, &ProviderAvailability::default(), &overrides);
let ids: Vec<_> = selections.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
vec![
"gpt-6-astra",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex-spark",
],
"openai-codex picker rows must be exactly the eight hardcoded OAuth slugs, in order"
);
}
fn codex_static_qualified_ids() -> Vec<&'static str> {
vec![
"openai-codex/gpt-6-astra",
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
"openai-codex/gpt-5.5",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.4-mini",
"openai-codex/gpt-5.3-codex-spark",
]
}
fn expanded_codex_state(load_state: ExpandedLoadState) -> ModelsModalState {
ModelsModalState {
cursor: 0,
search: String::new(),
view: ModelsView::All,
collapsed: HashSet::new(),
favorites: BTreeSet::new(),
expanded: Some(ExpandedModelsState {
provider_key: "openai-codex".into(),
provider_name: "OpenAI Codex".into(),
cursor: 0,
search: String::new(),
load_state,
}),
provider_catalog_overrides: std::collections::BTreeMap::new(),
}
}
#[test]
fn injected_openai_codex_result_cannot_affect_overrides_or_expanded_rows() {
let mut state = expanded_codex_state(ExpandedLoadState::Loading);
apply_model_list_result(
&mut state,
"openai-codex",
Ok(vec![
ExpandedModelEntry::new("openai-codex/o3".into(), "o3".into(), false),
ExpandedModelEntry::new(
"openai-codex/gpt-6-alpha".into(),
"GPT-6 Alpha".into(),
false,
),
]),
);
assert!(
!state
.provider_catalog_overrides
.contains_key("openai-codex"),
"live openai-codex rows must never populate main-section overrides: {:?}",
state.provider_catalog_overrides
);
let expanded = state.expanded.as_ref().expect("expanded state");
match &expanded.load_state {
ExpandedLoadState::Ready(models) => {
let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
codex_static_qualified_ids(),
"expanded OpenAI Codex rows must be exactly the eight static OAuth slugs"
);
}
other => panic!("expected static Ready rows, got {other:?}"),
}
}
#[test]
fn openai_codex_expanded_error_result_falls_back_to_static_rows() {
let mut state = expanded_codex_state(ExpandedLoadState::Loading);
apply_model_list_result(
&mut state,
"openai-codex",
Err("model list failed: HTTP 500".into()),
);
let expanded = state.expanded.as_ref().expect("expanded state");
match &expanded.load_state {
ExpandedLoadState::Ready(models) => {
let ids: Vec<_> = models.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, codex_static_qualified_ids());
}
other => panic!("expected static Ready rows, got {other:?}"),
}
}
#[test]
fn catalog_override_rows_never_emit_live_openai_codex_rows() {
let live = vec![ExpandedModelEntry::new(
"openai-codex/o3".into(),
"o3".into(),
false,
)];
assert!(
catalog_override_rows("openai-codex", &live).is_empty(),
"openai-codex override rows must be empty (static slugs are hardcoded)"
);
let anthropic = vec![ExpandedModelEntry::new(
"anthropic/claude-sonnet-4-7".into(),
"Sonnet 4.7".into(),
false,
)];
assert_eq!(
catalog_override_rows("anthropic", &anthropic),
vec![("claude-sonnet-4-7".to_string(), "Sonnet 4.7".to_string())],
"anthropic dynamic override rows must keep working"
);
}
}