use super::*;
const MODEL_PAIRS: &[(&str, &str)] = &[
("agent.compaction.model", "agent.compaction.provider"),
("agent.summarizer.model", "agent.summarizer.provider"),
("sessions.titles.model", "sessions.titles.provider"),
(
"capabilities.tools.view_image.vision_model.model",
"capabilities.tools.view_image.vision_model.provider",
),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ModelChoice {
id: String,
provider: String,
model: String,
pub label: String,
}
impl From<&crate::model_catalog::ModelCatalogEntry> for ModelChoice {
fn from(entry: &crate::model_catalog::ModelCatalogEntry) -> Self {
Self {
id: entry.id.clone(),
provider: entry.provider.clone(),
model: entry.model.clone(),
label: format!(
"{} · {}",
match entry.provider.as_str() {
"openai-codex" => "OpenAI Codex",
"anthropic" => "Anthropic",
other => other,
},
entry
.display_name
.as_ref()
.filter(|name| *name != &entry.model)
.map_or_else(
|| entry.model.clone(),
|name| format!("{name} ({})", entry.model)
)
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ModelPicker {
field_index: usize,
pub selected: usize,
pub choices: Vec<Option<ModelChoice>>,
pub default_label: &'static str,
}
impl SettingsEditor {
fn provider_field(&self, model_path: &str) -> Option<usize> {
let (_, provider) = MODEL_PAIRS.iter().find(|(model, _)| *model == model_path)?;
self.fields.iter().position(|field| field.path == *provider)
}
pub(super) fn include_paired_changes(&self, changes: &mut Vec<(&'static str, Value)>) {
for (model, provider) in MODEL_PAIRS {
if !changes
.iter()
.any(|(path, _)| path == model || path == provider)
{
continue;
}
for path in [model, provider] {
if !changes.iter().any(|(existing, _)| existing == path)
&& let Some(field) = self.fields.iter().find(|field| field.path == *path)
{
changes.push((field.path, field.value.clone()));
}
}
}
}
pub(super) fn model_selection_label(&self, field: &SettingsField) -> String {
let provider = self
.provider_field(field.path)
.map(|index| &self.fields[index].value);
if field.value.is_null() && provider.is_none_or(Value::is_null) {
return self.model_default_label(field.path).into();
}
let provider = provider
.and_then(Value::as_str)
.unwrap_or("active provider");
let model = field.value.as_str().unwrap_or("default model");
self.model_choices
.iter()
.find(|choice| choice.provider == provider && choice.model == model)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| format!("{provider} · {model}"))
}
fn model_default_label(&self, path: &str) -> &'static str {
match path {
"sessions.titles.model" => "No title model (titles disabled)",
"capabilities.tools.view_image.vision_model.model" => "No image model override",
_ => "Use active conversation model",
}
}
pub(super) fn open_model_picker(&mut self, field_index: usize) {
let field = &self.fields[field_index];
let allow_clear = field.path != "sessions.titles.model"
|| self
.fields
.iter()
.find(|field| field.path == "sessions.titles.enabled")
.is_some_and(|field| field.value == json!(false));
let mut choices = Vec::new();
if allow_clear {
choices.push(None);
}
choices.extend(
self.model_choices
.iter()
.filter(|choice| self.model_enabled(&choice.id))
.cloned()
.map(Some),
);
let provider = self
.provider_field(field.path)
.map(|index| &self.fields[index].value);
let selected = choices
.iter()
.position(|choice| match choice {
Some(choice) => {
field.value.as_str() == Some(&choice.model)
&& provider.and_then(Value::as_str) == Some(&choice.provider)
}
None => field.value.is_null() && provider.is_none_or(Value::is_null),
})
.unwrap_or(0);
self.model_picker = Some(ModelPicker {
field_index,
selected,
choices,
default_label: self.model_default_label(field.path),
});
}
pub(super) fn model_picker_key(&mut self, key: KeyEvent) -> bool {
let picker = self
.model_picker
.as_mut()
.expect("picker checked by caller");
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => self.model_picker = None,
(KeyCode::Tab, _) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => {
self.model_picker = None;
return false;
}
(KeyCode::Up, _) => picker.selected = picker.selected.saturating_sub(1),
(KeyCode::Down, _) => {
picker.selected = (picker.selected + 1).min(picker.choices.len().saturating_sub(1))
}
(KeyCode::Home, _) => picker.selected = 0,
(KeyCode::End, _) => picker.selected = picker.choices.len().saturating_sub(1),
(KeyCode::Enter, _) => {
let Some(choice) = picker.choices.get(picker.selected).cloned() else {
return true;
};
let field_index = picker.field_index;
let Some(provider_index) = self.provider_field(self.fields[field_index].path)
else {
return true;
};
self.fields[field_index].value = choice
.as_ref()
.map_or(Value::Null, |choice| json!(choice.model));
self.fields[provider_index].value = choice
.as_ref()
.map_or(Value::Null, |choice| json!(choice.provider));
self.model_picker = None;
self.notice = "Draft updated. Ctrl-S saves all changed fields.".into();
}
_ => {}
}
true
}
}