use super::single_line_field::SingleLineField;
use crate::config::{Settings, SettingsScope};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
mod model_picker;
mod render;
use model_picker::{ModelChoice, ModelPicker};
pub(crate) use render::draw;
#[cfg(test)]
mod tests;
pub(crate) const TABS: &[&str] = &["Context", "Agent settings", "Models", "Tools", "Other"];
pub(crate) const MODELS_TAB: usize = 2;
pub(super) fn models_scroll_offset(selected: usize, height: u16) -> usize {
selected.saturating_sub(usize::from(height.max(1)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
Boolean,
Number,
Text,
Paths,
Model,
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>,
model_choices: Vec<ModelChoice>,
pub notice: String,
pub busy: bool,
pub models: Vec<(String, 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,
}
impl SettingsEditor {
pub fn loading(scope: SettingsScope) -> Self {
Self {
scope,
tab: 0,
selected: 0,
fields: Vec::new(),
editing: None,
model_picker: None,
model_choices: Vec::new(),
notice: "Loading settings…".into(),
busy: true,
models: Vec::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 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.models.get(self.selected - 1).map(|row| row.0.clone()))
.flatten();
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.models
.iter()
.position(|row| row.0 == id)
.map(|index| index + 1)
})
.unwrap_or_else(|| self.selected.min(self.models.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)))
}
fn toggle_model(&mut self) {
let Some((id, _)) = self.models.get(self.selected.saturating_sub(1)) else {
return;
};
let Some(ids) = self
.fields
.iter_mut()
.find(|f| f.path == "providers.catalog.disabled")
.and_then(|f| f.value.as_array_mut())
else {
return;
};
if ids.iter().any(|v| v.as_str() == Some(id)) {
ids.retain(|v| v.as_str() != Some(id));
} else {
ids.push(json!(id));
}
ids.sort_by(|a, b| a.as_str().cmp(&b.as_str()));
}
pub fn key(&mut self, key: KeyEvent) -> EditorRequest {
if self.busy {
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::Down, _) => {
let last = if self.tab == MODELS_TAB {
self.models.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_model();
}
} else {
self.begin_edit();
}
}
_ => {}
}
EditorRequest::None
}
fn begin_edit(&mut self) {
let Some(index) = self.current_index() else {
return;
};
let field = &mut self.fields[index];
match field.kind {
FieldType::Boolean => field.value = json!(!field.value.as_bool().unwrap_or(false)),
FieldType::Model => self.open_model_picker(index),
FieldType::Hidden => {}
_ => {
let mut editor = SingleLineField::plain();
editor.set_value(value_text(&field.value));
self.editing = Some(editor);
}
}
}
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 parse_value(kind: FieldType, text: &str) -> Result<Value, &'static str> {
if kind == FieldType::Paths {
return Ok(json!(
text.split(';')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect::<Vec<_>>()
));
}
if text.trim().is_empty() {
return Ok(Value::Null);
}
match kind {
FieldType::Number => text
.trim()
.parse::<u64>()
.map(|n| json!(n))
.map_err(|_| "Enter a non-negative whole number, or leave blank for default."),
_ => Ok(json!(text.trim())),
}
}
pub(crate) fn value_text(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::String(s) => s.clone(),
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("; "),
_ => value.to_string(),
}
}
fn fields(s: &Settings) -> Vec<SettingsField> {
use FieldType::*;
let context = s.context.clone().unwrap_or_default();
let image = &s.tools.view_image;
let mut fields = Vec::new();
macro_rules! field {
($tab:expr, $path:literal, $label:literal, $kind:ident, $value:expr, $help:literal $(, $heading:literal)?) => {{
let value = json!($value);
fields.push(SettingsField {
path: $path,
label: $label,
help: $help,
tab: $tab,
section_heading: None $(.or(Some($heading)))?,
kind: $kind,
original: value.clone(),
value,
});
}};
}
field!(
0,
"agent.compaction.auto.enabled",
"Automatic compaction",
Boolean,
s.compaction.auto.enabled,
"Summarize older turns automatically.",
"Compaction"
);
field!(
0,
"agent.compaction.auto.threshold_percent",
"Auto-compact threshold (%)",
Number,
s.compaction.auto.threshold_percent,
"1–100; blank disables this threshold."
);
field!(
0,
"agent.compaction.auto.threshold_tokens",
"Auto-compact threshold (tokens)",
Number,
s.compaction.auto.threshold_tokens,
"Positive token count; blank disables this threshold."
);
field!(
0,
"agent.compaction.auto.max_compactions_per_run",
"Subagent auto compact limit",
Number,
s.compaction.auto.max_compactions_per_run,
"Automatic compactions per run (including subagents). 0 removes the count cap; blank uses default 4."
);
field!(
0,
"agent.compaction.provider",
"Compaction provider",
Hidden,
s.compaction.provider,
""
);
field!(
0,
"agent.compaction.model",
"Compaction model",
Model,
s.compaction.model,
"Choose an enabled model, or use the active conversation model."
);
field!(
0,
"agent.context.enabled",
"Fallback context budget",
Boolean,
context.enabled,
"Enable fallback context trimming.",
"Fallback context"
);
field!(
0,
"agent.context.max_tokens",
"Context token limit",
Number,
context.max_tokens,
"Fallback context capacity in tokens."
);
field!(
0,
"agent.context.reserve_tokens",
"Reserved output tokens",
Number,
context.reserve_tokens,
"Tokens reserved for output."
);
field!(
0,
"agent.context.keep_recent_tokens",
"Keep recent tokens",
Number,
context.keep_recent_tokens,
"Recent conversation tokens to retain."
);
field!(
1,
"agent.subagents.execution.absolute_paths",
"Allow outside-project paths",
Boolean,
s.tools.subagents.absolute_paths,
"Allow subagents to access paths outside the project.",
"Subagents"
);
field!(
1,
"agent.subagents.execution.max_depth",
"Maximum nesting depth",
Number,
s.tools.subagents.max_depth,
"Maximum nested subagent depth; validated on save."
);
field!(
1,
"agent.side.model",
"Side model",
Model,
s.side.model,
"Choose an enabled model, or inherit the primary provider/model when a side session starts.",
"Side agent"
);
field!(
1,
"agent.side.provider",
"Side provider",
Hidden,
s.side.provider,
""
);
field!(
1,
"agent.summarizer.auto_start",
"Start summarizer automatically",
Boolean,
s.summarizer.auto_start,
"Start session summaries automatically.",
"Summary agent"
);
field!(
1,
"agent.summarizer.model",
"Summary model",
Model,
s.summarizer.model,
"Choose an enabled model, or use the active conversation model."
);
field!(
1,
"agent.summarizer.provider",
"Summary provider",
Hidden,
s.summarizer.provider,
""
);
field!(
1,
"agent.summarizer.reasoning",
"Summary reasoning",
Text,
s.summarizer.reasoning,
"default, low, medium, high, x_high, max; blank uses default."
);
field!(
1,
"sessions.titles.enabled",
"Generate session titles",
Boolean,
s.session_titles.enabled,
"Automatically name sessions.",
"Session titles"
);
field!(
1,
"sessions.titles.model",
"Title model",
Model,
s.session_titles.model,
"Choose an enabled model. Clear only when session titles are disabled."
);
field!(
1,
"sessions.titles.provider",
"Title provider",
Hidden,
s.session_titles.provider,
""
);
field!(
4,
"automation.integrations.herdr.enabled",
"Herdr integration",
Boolean,
s.integrations.herdr.enabled,
"Enable Herdr integration after restart.",
"Integrations"
);
field!(
3,
"capabilities.tools.output_compression.enabled",
"Compress tool output",
Boolean,
s.tools.output_compression.enabled,
"Compress provider-visible tool output.",
"Output compression"
);
field!(
3,
"capabilities.tools.view_image.absolute_paths",
"Images outside project",
Boolean,
image.absolute_paths,
"Allow image reads outside the project.",
"View image"
);
field!(
3,
"capabilities.tools.view_image.max_image_bytes",
"Maximum image bytes",
Number,
image.max_image_bytes,
"Positive byte limit; validated on save."
);
field!(
3,
"capabilities.tools.view_image.vision_model.provider",
"Image provider",
Hidden,
image.vision_model.as_ref().map(|m| &m.provider),
""
);
field!(
3,
"capabilities.tools.view_image.vision_model.model",
"Image model",
Model,
image.vision_model.as_ref().map(|m| &m.model),
"Choose an enabled model, or clear the image model override."
);
field!(
4,
"interface.tui.subagent_card_rows",
"Subagent card rows",
Number,
s.tui.subagent_card_rows,
"1–50 rows; blank uses default.",
"Appearance"
);
field!(
4,
"interface.tui.autocomplete.respects_gitignore",
"Autocomplete respects gitignore",
Boolean,
s.file_autocomplete_respects_gitignore,
"Hide ignored files from file completion."
);
field!(
0,
"knowledge.skills.additional_paths",
"Additional skill directories",
Paths,
s.skills.additional_paths,
"Separate paths with semicolons; blank clears the list.",
"Skill directories"
);
field!(
1,
"providers.streaming.semantic_progress_timeout_seconds",
"Main stream timeout (seconds)",
Number,
s.provider_stream.semantic_progress_timeout_seconds,
"Seconds without semantic progress; blank uses default.",
"Streaming"
);
field!(
1,
"providers.streaming.subagent_semantic_progress_timeout_seconds",
"Subagent stream timeout (seconds)",
Number,
s.provider_stream.subagent_semantic_progress_timeout_seconds,
"Seconds without semantic progress; blank uses default."
);
fields
}
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) {
match mouse.kind {
MouseEventKind::ScrollDown => {
self.selected = (self.selected + 1).min(self.models.len())
}
MouseEventKind::ScrollUp => self.selected = self.selected.saturating_sub(1),
MouseEventKind::Down(MouseButton::Left) => {
let row = models_scroll_offset(self.selected, list.height)
+ usize::from(mouse.row - list.y);
if row < self.models.len() {
self.selected = row + 1;
self.toggle_model();
}
}
_ => {}
}
}
EditorRequest::None
}
}