mod fields;
use super::single_line_field::SingleLineField;
use crate::config::{Settings, SettingsScope};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use fields::fields;
use fields::parse_value;
pub(crate) use fields::value_text;
use serde_json::{Value, json};
mod model_picker;
mod render;
use model_picker::{ModelChoice, ModelPicker};
pub(crate) use render::{close_control_area, draw};
pub(crate) const TABS: &[&str] = &[
"Context",
"Agent settings",
"Models",
"Tools",
"Internal Tooling",
"Other",
];
pub(crate) const MODELS_TAB: usize = 2;
pub(super) fn models_scroll_offset(selected_row: usize, height: u16) -> usize {
selected_row
.saturating_add(1)
.saturating_sub(usize::from(height.max(1)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ModelControl {
Provider { start: usize, end: usize },
Model(usize),
}
const REASONING_CHOICES: &[&str] = &["default", "low", "medium", "high", "x_high", "max"];
const PROTECTION_LEVEL_CHOICES: &[&str] =
&["permissive", "relaxed", "balanced", "cautious", "strict"];
const FAILURE_POLICY_CHOICES: &[&str] = &["auto_approve", "auto_deny"];
const PROMPT_INJECTION_FAILURE_POLICY_CHOICES: &[&str] = &["annotate", "quarantine"];
const PROMPT_INJECTION_TOOL_CHOICES: &[&str] = &[
"read",
"view_image",
"bash",
"hash_edit",
"write",
"grep",
"find",
"list_files",
"subagents",
"web",
"ast_grep",
"magi_control",
];
const COMPLETION_VERIFICATION_FAILURE_POLICY_CHOICES: &[&str] =
&["accept_with_warning", "reject_with_warning"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChoicePicker {
field_index: usize,
selected: usize,
choices: &'static [&'static str],
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MultiChoicePicker {
field_index: usize,
selected: usize,
choices: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
Boolean,
Number,
Probability,
Paths,
Model,
Choice(&'static [&'static str]),
MultiChoice(&'static [&'static str]),
Action,
Hidden,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SettingsField {
pub path: &'static str,
pub label: &'static str,
pub help: &'static str,
pub tab: usize,
section_heading: Option<&'static str>,
kind: FieldType,
original: Value,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SettingsEditor {
pub scope: SettingsScope,
pub tab: usize,
pub selected: usize,
pub fields: Vec<SettingsField>,
pub editing: Option<SingleLineField>,
model_picker: Option<ModelPicker>,
choice_picker: Option<ChoicePicker>,
multi_choice_picker: Option<MultiChoicePicker>,
model_choices: Vec<ModelChoice>,
pub notice: String,
pub busy: bool,
pub models: Vec<(String, String)>,
collapsed_providers: std::collections::BTreeSet<String>,
pub catalog_loading: bool,
pub catalog_notice: String,
discard_armed: bool,
restart_required: bool,
}
pub(crate) enum EditorRequest {
None,
Close,
Load(SettingsScope),
Save,
RefreshCatalog,
ResetJevCache,
OpenTools,
OpenCodeModeTools,
}
impl SettingsEditor {
pub fn loading(scope: SettingsScope) -> Self {
Self {
scope,
tab: 0,
selected: 0,
fields: Vec::new(),
editing: None,
model_picker: None,
choice_picker: None,
multi_choice_picker: None,
model_choices: Vec::new(),
notice: "Loading settings…".into(),
busy: true,
models: Vec::new(),
collapsed_providers: std::collections::BTreeSet::new(),
catalog_loading: false,
catalog_notice: String::new(),
discard_armed: false,
restart_required: false,
}
}
pub fn loaded(&mut self, settings: &Settings) {
self.fields = fields(settings);
let value = json!(settings.models.disabled);
self.fields.push(SettingsField {
path: "providers.catalog.disabled",
label: "Models",
help: "",
tab: usize::MAX,
section_heading: None,
kind: FieldType::Paths,
original: value.clone(),
value,
});
self.busy = false;
self.notice = match self.scope {
SettingsScope::Global => "You are editing global settings.",
SettingsScope::Project => "Project values include inherited global settings.",
}
.into();
}
pub fn changes(&self) -> Vec<(&'static str, Value)> {
let image_fields: Vec<_> = self
.fields
.iter()
.filter(|f| {
f.path
.starts_with("capabilities.tools.view_image.vision_model.")
})
.collect();
let clearing_image = image_fields.iter().all(|f| f.value.is_null())
&& image_fields.iter().any(|f| !f.original.is_null());
let mut changes: Vec<_> = self
.fields
.iter()
.filter(|f| f.value != f.original)
.filter(|f| {
!(clearing_image
&& f.path
.starts_with("capabilities.tools.view_image.vision_model."))
})
.map(|f| (f.path, f.value.clone()))
.collect();
if clearing_image {
changes.push(("capabilities.tools.view_image.vision_model", Value::Null));
}
self.include_paired_changes(&mut changes);
changes
}
pub fn mark_saved(&mut self) {
if self.changes().is_empty() {
return;
}
for field in &mut self.fields {
field.original = field.value.clone();
}
self.restart_required = true;
self.notice = "Settings saved. Restart magi-code to apply changes.".into();
}
pub fn sync_code_mode_settings(&mut self, settings: &crate::config::CodeModeToolSettings) {
if let Some(field) = self
.fields
.iter_mut()
.find(|field| field.path == "capabilities.tools.code_mode.compact_direct_tools")
{
let value = json!(settings.compact_direct_tools);
field.original = value.clone();
field.value = value;
}
}
pub fn insert_paste(&mut self, text: &str) {
if self.busy {
return;
}
if let Some(editor) = &mut self.editing {
if editor.value().len().saturating_add(text.len()) > 16384 {
self.notice = "Setting text is too large (maximum 16384 bytes).".into();
} else {
editor.insert_str(text);
}
}
}
pub fn visible_fields(&self) -> Vec<&SettingsField> {
self.fields
.iter()
.filter(|f| f.tab == self.tab && f.kind != FieldType::Hidden)
.collect()
}
fn current_index(&self) -> Option<usize> {
self.fields
.iter()
.enumerate()
.filter(|(_, f)| f.tab == self.tab && f.kind != FieldType::Hidden)
.nth(self.selected)
.map(|(i, _)| i)
}
pub fn set_catalog(&mut self, catalog: &crate::model_catalog::CatalogForUi) {
self.model_choices = catalog.entries.iter().map(ModelChoice::from).collect();
let selected_id = (self.tab == MODELS_TAB && self.selected > 0)
.then(|| self.model_controls().get(self.selected - 1).copied())
.flatten()
.and_then(|control| match control {
ModelControl::Model(index) => self.models.get(index).map(|row| row.0.clone()),
ModelControl::Provider { .. } => None,
});
self.models = catalog
.entries
.iter()
.map(|entry| {
(
entry.id.clone(),
format!(
"{} · {}",
entry.id,
entry.display_name.as_deref().unwrap_or(&entry.model)
),
)
})
.collect();
self.models.sort_by(|a, b| a.0.cmp(&b.0));
self.models.dedup_by(|a, b| a.0 == b.0);
if self.tab == MODELS_TAB {
self.selected = selected_id
.and_then(|id| {
self.model_controls()
.iter()
.position(|control| match control {
ModelControl::Model(index) => self.models[*index].0 == id,
ModelControl::Provider { .. } => false,
})
.map(|index| index + 1)
})
.unwrap_or_else(|| self.selected.min(self.model_controls().len()));
}
self.catalog_notice = catalog.notice.clone().unwrap_or_else(|| {
if catalog.stale {
"Using stale catalog."
} else {
"Catalog loaded."
}
.into()
});
}
pub fn model_enabled(&self, id: &str) -> bool {
!self
.fields
.iter()
.find(|f| f.path == "providers.catalog.disabled")
.and_then(|f| f.value.as_array())
.is_some_and(|ids| ids.iter().any(|v| v.as_str() == Some(id)))
}
pub(super) fn model_controls(&self) -> Vec<ModelControl> {
let mut controls = Vec::new();
let mut start = 0;
while start < self.models.len() {
let provider = model_provider(&self.models[start].0);
let mut end = start + 1;
while end < self.models.len() && model_provider(&self.models[end].0) == provider {
end += 1;
}
controls.push(ModelControl::Provider { start, end });
if !self.collapsed_providers.contains(provider) {
controls.extend((start..end).map(ModelControl::Model));
}
start = end;
}
controls
}
pub(super) fn model_rows(&self) -> Vec<Option<usize>> {
let controls = self.model_controls();
let mut rows = Vec::new();
for (index, control) in controls.iter().enumerate() {
rows.push(Some(index));
let ends_expanded_group = matches!(control, ModelControl::Model(_))
&& !matches!(controls.get(index + 1), Some(ModelControl::Model(_)));
if ends_expanded_group {
rows.push(None);
}
}
rows
}
pub(super) fn model_control_document_row(&self, control_index: usize) -> usize {
self.model_rows()
.iter()
.position(|row| *row == Some(control_index))
.unwrap_or(0)
}
pub(super) fn model_control_at_document_row(&self, row: usize) -> Option<usize> {
self.model_rows().get(row).copied().flatten()
}
pub(super) fn provider_collapsed(&self, provider: &str) -> bool {
self.collapsed_providers.contains(provider)
}
fn set_selected_provider_collapsed(&mut self, collapsed: bool) {
let controls = self.model_controls();
let Some(controls_through_selected) = self
.selected
.checked_sub(1)
.and_then(|index| controls.get(..=index))
else {
return;
};
let Some(header_index) = controls_through_selected
.iter()
.rposition(|control| matches!(control, ModelControl::Provider { .. }))
else {
return;
};
let ModelControl::Provider { start, .. } = controls[header_index] else {
return;
};
let provider = model_provider(&self.models[start].0).to_owned();
if collapsed {
self.collapsed_providers.insert(provider);
self.selected = header_index + 1;
} else {
self.collapsed_providers.remove(&provider);
}
}
fn toggle_selected_model_control(&mut self) {
let Some(control) = self
.model_controls()
.get(self.selected.saturating_sub(1))
.copied()
else {
return;
};
match control {
ModelControl::Model(index) => self.toggle_model_at(index),
ModelControl::Provider { start, end } => {
let enable = (start..end).any(|index| !self.model_enabled(&self.models[index].0));
for index in start..end {
self.set_model_enabled_at(index, enable);
}
}
}
}
fn set_model_enabled_at(&mut self, index: usize, enabled: bool) {
let Some((id, _)) = self.models.get(index) else {
return;
};
let id = id.clone();
let Some(ids) = self
.fields
.iter_mut()
.find(|field| field.path == "providers.catalog.disabled")
.and_then(|field| field.value.as_array_mut())
else {
return;
};
ids.retain(|value| value.as_str() != Some(&id));
if !enabled {
ids.push(json!(id));
}
ids.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
}
fn toggle_model_at(&mut self, index: usize) {
let enabled = self
.models
.get(index)
.is_some_and(|(id, _)| self.model_enabled(id));
self.set_model_enabled_at(index, !enabled);
}
pub fn key(&mut self, key: KeyEvent) -> EditorRequest {
if self.busy {
return EditorRequest::None;
}
if self.multi_choice_picker.is_some() {
self.multi_choice_picker_key(key);
return EditorRequest::None;
}
if self.choice_picker.is_some() {
self.choice_picker_key(key);
return EditorRequest::None;
}
if self.model_picker.is_some() && self.model_picker_key(key) {
return EditorRequest::None;
}
if self.editing.is_some() {
self.edit_key(key);
return EditorRequest::None;
}
if key.code != KeyCode::Esc {
self.discard_armed = false;
}
let dirty = !self.changes().is_empty();
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
if dirty && !self.discard_armed {
self.notice =
"Unsaved changes: Esc again discards; any other key keeps editing.".into();
self.discard_armed = true;
return EditorRequest::None;
}
return EditorRequest::Close;
}
(KeyCode::Char('s'), KeyModifiers::CONTROL) => {
if dirty {
return EditorRequest::Save;
}
self.notice = if self.restart_required {
"No changes to save. Restart magi-code to apply previously saved changes."
} else {
"No changes to save."
}
.into();
}
(KeyCode::Char('g'), KeyModifiers::CONTROL) => {
if dirty {
self.notice = "Save or discard changes before switching scope.".into();
} else {
return EditorRequest::Load(match self.scope {
SettingsScope::Global => SettingsScope::Project,
SettingsScope::Project => SettingsScope::Global,
});
}
}
(KeyCode::Tab, modifiers) if !modifiers.contains(KeyModifiers::SHIFT) => {
self.tab = (self.tab + 1) % TABS.len();
self.selected = 0;
}
(KeyCode::Up, _) => self.selected = self.selected.saturating_sub(1),
(KeyCode::Left, _) if self.tab == MODELS_TAB => {
self.set_selected_provider_collapsed(true)
}
(KeyCode::Right, _) if self.tab == MODELS_TAB => {
self.set_selected_provider_collapsed(false)
}
(KeyCode::Down, _) => {
let last = if self.tab == MODELS_TAB {
self.model_controls().len()
} else {
self.visible_fields().len().saturating_sub(1)
};
self.selected = (self.selected + 1).min(last);
}
(KeyCode::Enter, _) | (KeyCode::Char(' '), KeyModifiers::NONE) => {
if self.tab == MODELS_TAB {
if self.selected == 0 {
if key.code == KeyCode::Enter && !self.catalog_loading {
return EditorRequest::RefreshCatalog;
}
} else {
self.toggle_selected_model_control();
}
} else if let Some(index) = self
.current_index()
.filter(|index| self.fields[*index].kind == FieldType::Action)
{
return if matches!(
self.fields[index].path,
"capabilities.tools.manage" | "capabilities.tools.code_mode.manage"
) {
if dirty {
self.notice =
"Save or discard changes before managing tool availability.".into();
EditorRequest::None
} else if self.fields[index].path == "capabilities.tools.code_mode.manage" {
EditorRequest::OpenCodeModeTools
} else {
EditorRequest::OpenTools
}
} else {
EditorRequest::ResetJevCache
};
} else {
self.begin_edit();
}
}
_ => {}
}
EditorRequest::None
}
fn begin_edit(&mut self) {
let Some(index) = self.current_index() else {
return;
};
let kind = self.fields[index].kind;
match kind {
FieldType::Boolean => {
let field = &mut self.fields[index];
field.value = json!(!field.value.as_bool().unwrap_or(false));
}
FieldType::Model => self.open_model_picker(index),
FieldType::Choice(choices) => self.open_choice_picker(index, choices),
FieldType::MultiChoice(choices) => self.open_multi_choice_picker(index, choices),
FieldType::Hidden | FieldType::Action => {}
_ => {
let mut editor = SingleLineField::plain();
editor.set_value(value_text(&self.fields[index].value));
self.editing = Some(editor);
}
}
}
fn open_choice_picker(&mut self, field_index: usize, choices: &'static [&'static str]) {
let current = self.fields[field_index].value.as_str().unwrap_or("default");
let selected = choices
.iter()
.position(|choice| *choice == current)
.unwrap_or(0);
self.choice_picker = Some(ChoicePicker {
field_index,
selected,
choices,
});
}
fn choice_picker_key(&mut self, key: KeyEvent) {
let Some(picker) = self.choice_picker.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => self.choice_picker = None,
KeyCode::Up => picker.selected = picker.selected.saturating_sub(1),
KeyCode::Down => picker.selected = (picker.selected + 1).min(picker.choices.len() - 1),
KeyCode::Home => picker.selected = 0,
KeyCode::End => picker.selected = picker.choices.len() - 1,
KeyCode::Enter => {
let value = picker.choices[picker.selected];
self.fields[picker.field_index].value = if value == "default" {
Value::Null
} else {
json!(value)
};
self.choice_picker = None;
self.notice = "Draft updated. Ctrl-S saves all changed fields.".into();
}
_ => {}
}
}
fn open_multi_choice_picker(&mut self, field_index: usize, choices: &'static [&'static str]) {
self.multi_choice_picker = Some(MultiChoicePicker {
field_index,
selected: 0,
choices,
});
}
fn multi_choice_picker_key(&mut self, key: KeyEvent) {
let Some(picker) = self.multi_choice_picker.as_mut() else {
return;
};
match key.code {
KeyCode::Esc => self.multi_choice_picker = None,
KeyCode::Up => picker.selected = picker.selected.saturating_sub(1),
KeyCode::Down => picker.selected = (picker.selected + 1).min(picker.choices.len() - 1),
KeyCode::Home => picker.selected = 0,
KeyCode::End => picker.selected = picker.choices.len() - 1,
KeyCode::Enter | KeyCode::Char(' ') => {
let choice = picker.choices[picker.selected];
let values = self.fields[picker.field_index]
.value
.as_array_mut()
.expect("multi-choice setting must be an array");
if values.iter().any(|value| value.as_str() == Some(choice)) {
values.retain(|value| value.as_str() != Some(choice));
} else {
values.push(json!(choice));
values.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
}
self.notice = "Draft updated. Esc returns; Ctrl-S saves all changed fields.".into();
}
_ => {}
}
}
fn edit_key(&mut self, key: KeyEvent) {
let Some(index) = self.current_index() else {
return;
};
let editor = self.editing.as_mut().expect("editing checked by caller");
match key.code {
KeyCode::Esc => self.editing = None,
KeyCode::Enter => match parse_value(self.fields[index].kind, editor.value()) {
Ok(value) => {
self.fields[index].value = value;
self.editing = None;
self.notice = "Draft updated. Ctrl-S saves all changed fields.".into();
}
Err(error) => self.notice = error.into(),
},
KeyCode::Backspace => {
editor.backspace();
}
KeyCode::Delete => {
editor.delete_forward();
}
KeyCode::Left => editor.move_left(),
KeyCode::Right => editor.move_right(),
KeyCode::Home => editor.move_home(),
KeyCode::End => editor.move_end(),
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& editor.value().len().saturating_add(c.len_utf8()) <= 16384 =>
{
editor.insert_char(c);
}
_ => {}
}
}
}
fn model_provider(id: &str) -> &str {
id.split_once('/').map_or(id, |(provider, _)| provider)
}
const MODEL_FOLD_ARROW_COLUMNS: u16 = 5;
impl SettingsEditor {
pub fn mouse(
&mut self,
mouse: crossterm::event::MouseEvent,
area: ratatui::layout::Rect,
) -> EditorRequest {
use crossterm::event::{MouseButton, MouseEventKind};
if self.busy || self.tab != MODELS_TAB {
return EditorRequest::None;
}
let (button, list) = render::models_geometry(area, self);
let point = ratatui::layout::Position::new(mouse.column, mouse.row);
if button.contains(point) && mouse.kind == MouseEventKind::Down(MouseButton::Left) {
self.selected = 0;
if !self.catalog_loading {
return EditorRequest::RefreshCatalog;
}
}
let list = ratatui::layout::Rect {
height: list.height.saturating_sub(2),
..list
};
if list.contains(point) {
let controls = self.model_controls();
match mouse.kind {
MouseEventKind::ScrollDown => {
self.selected = (self.selected + 1).min(controls.len())
}
MouseEventKind::ScrollUp => self.selected = self.selected.saturating_sub(1),
MouseEventKind::Down(MouseButton::Left) => {
let selected_row =
self.model_control_document_row(self.selected.saturating_sub(1));
let document_row = models_scroll_offset(selected_row, list.height)
+ usize::from(mouse.row - list.y);
if let Some(control_index) = self.model_control_at_document_row(document_row) {
self.selected = control_index + 1;
let clicked_fold_arrow = mouse.column < list.x + MODEL_FOLD_ARROW_COLUMNS;
match self.model_controls()[control_index] {
ModelControl::Provider { start, .. } if clicked_fold_arrow => {
let provider = model_provider(&self.models[start].0);
self.set_selected_provider_collapsed(
!self.provider_collapsed(provider),
);
}
_ => self.toggle_selected_model_control(),
}
}
}
_ => {}
}
}
EditorRequest::None
}
}