magi-code 0.80.2

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;

const MODEL_PAIRS: &[(&str, &str)] = &[
    ("agent.compaction.model", "agent.compaction.provider"),
    ("agent.summarizer.model", "agent.summarizer.provider"),
    ("agent.side.model", "agent.side.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",
            "agent.side.model" => "Use primary provider/model",
            _ => "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),
        });
    }

    // Tab and scope changes cancel the unaccepted choice before normal navigation.
    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
    }
}