Skip to main content

imp_tui/views/
settings.rs

1use imp_core::config::{
2    AnimationLevel, ChatToolDisplay, Config, ContextConfig, ContinuePolicy, SidebarStyle,
3    ToolOutputDisplay,
4};
5use imp_core::tools::web::types::SearchProvider;
6use imp_llm::auth::AuthStore;
7use imp_llm::model::ModelMeta;
8use imp_llm::ThinkingLevel;
9use ratatui::buffer::Buffer;
10use ratatui::layout::Rect;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Clear, Widget};
14
15use crate::theme::Theme;
16
17/// Which field in the settings panel is focused.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SettingsField {
20    Model,
21    ChosenModels,
22    Theme,
23    ThinkingLevel,
24    MaxTokens,
25    MaxTurns,
26    ObservationMask,
27    ReadMaxLines,
28    SidebarWidth,
29    WordWrap,
30    Animations,
31    AutoOpenSidebar,
32    SidebarAutoOpenWidth,
33    ThinkingLines,
34    StreamingLines,
35    MouseScrollLines,
36    KeyboardScrollLines,
37    ShowTimestamps,
38    ShowCost,
39    ShowContextUsage,
40    NotifyOnAgentComplete,
41    ContinuePolicy,
42    WebSearchProvider,
43    TavilyApiKey,
44    ExaApiKey,
45    Save,
46}
47
48const FIELDS: &[SettingsField] = &[
49    SettingsField::Model,
50    SettingsField::ChosenModels,
51    SettingsField::Theme,
52    SettingsField::ThinkingLevel,
53    SettingsField::MaxTokens,
54    SettingsField::MaxTurns,
55    SettingsField::ObservationMask,
56    SettingsField::ReadMaxLines,
57    SettingsField::SidebarWidth,
58    SettingsField::WordWrap,
59    SettingsField::Animations,
60    SettingsField::AutoOpenSidebar,
61    SettingsField::SidebarAutoOpenWidth,
62    SettingsField::ThinkingLines,
63    SettingsField::StreamingLines,
64    SettingsField::MouseScrollLines,
65    SettingsField::KeyboardScrollLines,
66    SettingsField::ShowTimestamps,
67    SettingsField::ShowCost,
68    SettingsField::ShowContextUsage,
69    SettingsField::NotifyOnAgentComplete,
70    SettingsField::ContinuePolicy,
71    SettingsField::WebSearchProvider,
72    SettingsField::TavilyApiKey,
73    SettingsField::ExaApiKey,
74    SettingsField::Save,
75];
76
77fn field_index(field: SettingsField) -> usize {
78    FIELDS
79        .iter()
80        .position(|candidate| *candidate == field)
81        .expect("settings field is registered")
82}
83
84/// State for the settings overlay.
85#[derive(Debug, Clone)]
86pub struct SettingsState {
87    pub selected: usize,
88    pub model: String,
89    pub model_options: Vec<String>,
90    pub chosen_models: Vec<String>,
91    pub theme_name: String,
92    pub theme_options: Vec<String>,
93    pub thinking_level: ThinkingLevel,
94    pub max_tokens: u32,
95    pub max_turns: u32,
96    pub observation_mask: f64,
97    pub sidebar_style: SidebarStyle,
98    pub tool_output: ToolOutputDisplay,
99    pub tool_output_lines: usize,
100    pub read_max_lines: usize,
101    pub sidebar_width: u16,
102    pub word_wrap: bool,
103    pub animations: AnimationLevel,
104    pub chat_tool_display: ChatToolDisplay,
105    pub auto_open_sidebar: bool,
106    pub sidebar_auto_open_width: u16,
107    pub thinking_lines: usize,
108    pub streaming_lines: usize,
109    pub mouse_scroll_lines: usize,
110    pub keyboard_scroll_lines: usize,
111    pub show_timestamps: bool,
112    pub show_cost: bool,
113    pub show_context_usage: bool,
114    pub notify_on_agent_complete: bool,
115    pub continue_policy: ContinuePolicy,
116    pub web_search_provider: Option<SearchProvider>,
117    pub tavily_api_key: String,
118    pub exa_api_key: String,
119    pub tavily_configured: bool,
120    pub exa_configured: bool,
121    pub editing_number: bool,
122    pub edit_buffer: String,
123    pub dirty: bool,
124}
125
126impl SettingsState {
127    fn normalized_selected(&self) -> usize {
128        self.selected.min(FIELDS.len().saturating_sub(1))
129    }
130
131    pub fn new(
132        config: &Config,
133        model_name: &str,
134        models: &[ModelMeta],
135        auth_store: &AuthStore,
136    ) -> Self {
137        Self {
138            selected: 0,
139            model: model_name.to_string(),
140            model_options: models.iter().map(|m| m.id.clone()).collect(),
141            chosen_models: config.enabled_models.clone().unwrap_or_default(),
142            theme_name: config.theme.clone().unwrap_or_else(|| "default".into()),
143            theme_options: theme_options(config.theme.as_deref()),
144            thinking_level: config.thinking.unwrap_or(ThinkingLevel::Medium),
145            max_tokens: config.max_tokens.unwrap_or(4096),
146            max_turns: config.max_turns.unwrap_or(100),
147            observation_mask: config.context.observation_mask_threshold,
148            sidebar_style: config.ui.sidebar_style,
149            tool_output: config.ui.tool_output,
150            tool_output_lines: config.ui.tool_output_lines,
151            read_max_lines: config.ui.read_max_lines,
152            sidebar_width: config.ui.sidebar_width,
153            word_wrap: config.ui.word_wrap,
154            animations: config.ui.animations,
155            chat_tool_display: config.ui.effective_chat_tool_display(),
156            auto_open_sidebar: config.ui.auto_open_sidebar,
157            sidebar_auto_open_width: config.ui.sidebar_auto_open_width,
158            thinking_lines: config.ui.thinking_lines,
159            streaming_lines: config.ui.streaming_lines,
160            mouse_scroll_lines: config.ui.mouse_scroll_lines,
161            keyboard_scroll_lines: config.ui.keyboard_scroll_lines,
162            show_timestamps: config.ui.show_timestamps,
163            show_cost: config.ui.show_cost,
164            show_context_usage: config.ui.show_context_usage,
165            notify_on_agent_complete: config.ui.notify_on_agent_complete,
166            continue_policy: config.ui.continue_policy,
167            web_search_provider: config.web.search_provider,
168            tavily_api_key: String::new(),
169            exa_api_key: String::new(),
170            tavily_configured: auth_store.stored.contains_key("tavily")
171                || std::env::var("TAVILY_API_KEY").is_ok(),
172            exa_configured: auth_store.stored.contains_key("exa")
173                || std::env::var("EXA_API_KEY").is_ok(),
174            editing_number: false,
175            edit_buffer: String::new(),
176            dirty: false,
177        }
178    }
179
180    pub fn current_field(&self) -> SettingsField {
181        FIELDS[self.normalized_selected()]
182    }
183
184    pub fn move_up(&mut self) {
185        self.commit_edit();
186        if self.selected > 0 {
187            self.selected -= 1;
188        }
189    }
190
191    pub fn move_down(&mut self) {
192        self.commit_edit();
193        if self.selected + 1 < FIELDS.len() {
194            self.selected += 1;
195        }
196    }
197
198    /// Cycle the current field's value forward.
199    pub fn cycle_forward(&mut self) {
200        self.dirty = true;
201        match self.current_field() {
202            SettingsField::Model => {
203                if !self.model_options.is_empty() {
204                    if let Some(idx) = self.model_options.iter().position(|m| *m == self.model) {
205                        let next = (idx + 1) % self.model_options.len();
206                        self.model = self.model_options[next].clone();
207                    }
208                }
209            }
210            SettingsField::ChosenModels => {
211                self.toggle_current_model_in_chosen();
212            }
213            SettingsField::Theme => {
214                if !self.theme_options.is_empty() {
215                    let idx = self
216                        .theme_options
217                        .iter()
218                        .position(|t| *t == self.theme_name)
219                        .unwrap_or(0);
220                    let next = (idx + 1) % self.theme_options.len();
221                    self.theme_name = self.theme_options[next].clone();
222                }
223            }
224            SettingsField::ThinkingLevel => {
225                self.thinking_level = next_thinking(self.thinking_level);
226            }
227            SettingsField::MaxTokens => {
228                self.max_tokens = self.max_tokens.saturating_add(256).min(128_000);
229            }
230            SettingsField::MaxTurns => {
231                self.max_turns = self.max_turns.saturating_add(10);
232            }
233            SettingsField::ObservationMask => {
234                self.observation_mask = (self.observation_mask + 0.05).min(1.0);
235            }
236            SettingsField::ReadMaxLines => {
237                self.read_max_lines = self.read_max_lines.saturating_add(100);
238            }
239            SettingsField::SidebarWidth => {
240                self.sidebar_width = (self.sidebar_width + 5).min(80);
241            }
242            SettingsField::WordWrap => {
243                self.word_wrap = !self.word_wrap;
244            }
245            SettingsField::Animations => {
246                self.animations = match self.animations {
247                    AnimationLevel::None => AnimationLevel::Spinner,
248                    AnimationLevel::Spinner => AnimationLevel::Minimal,
249                    AnimationLevel::Minimal => AnimationLevel::None,
250                };
251            }
252            SettingsField::AutoOpenSidebar => {
253                self.auto_open_sidebar = !self.auto_open_sidebar;
254            }
255            SettingsField::SidebarAutoOpenWidth => {
256                self.sidebar_auto_open_width = (self.sidebar_auto_open_width + 10).min(240);
257            }
258            SettingsField::ThinkingLines => {
259                self.thinking_lines = self.thinking_lines.saturating_add(1).min(20);
260            }
261            SettingsField::StreamingLines => {
262                self.streaming_lines = self.streaming_lines.saturating_add(1).min(20);
263            }
264            SettingsField::MouseScrollLines => {
265                self.mouse_scroll_lines = self.mouse_scroll_lines.saturating_add(1).min(20);
266            }
267            SettingsField::KeyboardScrollLines => {
268                self.keyboard_scroll_lines = self.keyboard_scroll_lines.saturating_add(5).min(100);
269            }
270            SettingsField::ShowTimestamps => {
271                self.show_timestamps = !self.show_timestamps;
272            }
273            SettingsField::ShowCost => {
274                self.show_cost = !self.show_cost;
275            }
276            SettingsField::ShowContextUsage => {
277                self.show_context_usage = !self.show_context_usage;
278            }
279            SettingsField::NotifyOnAgentComplete => {
280                self.notify_on_agent_complete = !self.notify_on_agent_complete;
281            }
282            SettingsField::ContinuePolicy => {
283                self.continue_policy = match self.continue_policy {
284                    ContinuePolicy::Disabled => ContinuePolicy::Conservative,
285                    ContinuePolicy::Conservative => ContinuePolicy::Balanced,
286                    ContinuePolicy::Balanced => ContinuePolicy::Aggressive,
287                    ContinuePolicy::Aggressive => ContinuePolicy::Disabled,
288                };
289            }
290            SettingsField::WebSearchProvider => {
291                self.web_search_provider = match self.web_search_provider {
292                    None => Some(SearchProvider::Tavily),
293                    Some(SearchProvider::Tavily) => Some(SearchProvider::Exa),
294                    Some(SearchProvider::Exa) => Some(SearchProvider::Linkup),
295                    Some(SearchProvider::Linkup) => Some(SearchProvider::Perplexity),
296                    Some(SearchProvider::Perplexity) => None,
297                };
298            }
299            SettingsField::TavilyApiKey => {}
300            SettingsField::ExaApiKey => {}
301            SettingsField::Save => {}
302        }
303    }
304
305    /// Cycle the current field's value backward.
306    pub fn cycle_backward(&mut self) {
307        self.dirty = true;
308        match self.current_field() {
309            SettingsField::Model => {
310                if !self.model_options.is_empty() {
311                    if let Some(idx) = self.model_options.iter().position(|m| *m == self.model) {
312                        let prev = if idx == 0 {
313                            self.model_options.len() - 1
314                        } else {
315                            idx - 1
316                        };
317                        self.model = self.model_options[prev].clone();
318                    }
319                }
320            }
321            SettingsField::ChosenModels => {
322                self.toggle_current_model_in_chosen();
323            }
324            SettingsField::Theme => {
325                if !self.theme_options.is_empty() {
326                    let idx = self
327                        .theme_options
328                        .iter()
329                        .position(|t| *t == self.theme_name)
330                        .unwrap_or(0);
331                    let prev = if idx == 0 {
332                        self.theme_options.len() - 1
333                    } else {
334                        idx - 1
335                    };
336                    self.theme_name = self.theme_options[prev].clone();
337                }
338            }
339            SettingsField::ThinkingLevel => {
340                self.thinking_level = prev_thinking(self.thinking_level);
341            }
342            SettingsField::MaxTokens => {
343                self.max_tokens = self.max_tokens.saturating_sub(256).max(1);
344            }
345            SettingsField::MaxTurns => {
346                self.max_turns = self.max_turns.saturating_sub(10).max(1);
347            }
348            SettingsField::ObservationMask => {
349                self.observation_mask = (self.observation_mask - 0.05).max(0.0);
350            }
351            SettingsField::ReadMaxLines => {
352                self.read_max_lines = self.read_max_lines.saturating_sub(100);
353            }
354            SettingsField::SidebarWidth => {
355                self.sidebar_width = self.sidebar_width.saturating_sub(5).max(20);
356            }
357            SettingsField::WordWrap => {
358                self.word_wrap = !self.word_wrap;
359            }
360            SettingsField::Animations => {
361                self.animations = match self.animations {
362                    AnimationLevel::None => AnimationLevel::Minimal,
363                    AnimationLevel::Spinner => AnimationLevel::None,
364                    AnimationLevel::Minimal => AnimationLevel::Spinner,
365                };
366            }
367            SettingsField::AutoOpenSidebar => {
368                self.auto_open_sidebar = !self.auto_open_sidebar;
369            }
370            SettingsField::SidebarAutoOpenWidth => {
371                self.sidebar_auto_open_width =
372                    self.sidebar_auto_open_width.saturating_sub(10).max(40);
373            }
374            SettingsField::ThinkingLines => {
375                self.thinking_lines = self.thinking_lines.saturating_sub(1).max(1);
376            }
377            SettingsField::StreamingLines => {
378                self.streaming_lines = self.streaming_lines.saturating_sub(1).max(1);
379            }
380            SettingsField::MouseScrollLines => {
381                self.mouse_scroll_lines = self.mouse_scroll_lines.saturating_sub(1).max(1);
382            }
383            SettingsField::KeyboardScrollLines => {
384                self.keyboard_scroll_lines = self.keyboard_scroll_lines.saturating_sub(5).max(5);
385            }
386            SettingsField::ShowTimestamps => {
387                self.show_timestamps = !self.show_timestamps;
388            }
389            SettingsField::ShowCost => {
390                self.show_cost = !self.show_cost;
391            }
392            SettingsField::ShowContextUsage => {
393                self.show_context_usage = !self.show_context_usage;
394            }
395            SettingsField::NotifyOnAgentComplete => {
396                self.notify_on_agent_complete = !self.notify_on_agent_complete;
397            }
398            SettingsField::ContinuePolicy => {
399                self.continue_policy = match self.continue_policy {
400                    ContinuePolicy::Disabled => ContinuePolicy::Aggressive,
401                    ContinuePolicy::Conservative => ContinuePolicy::Disabled,
402                    ContinuePolicy::Balanced => ContinuePolicy::Conservative,
403                    ContinuePolicy::Aggressive => ContinuePolicy::Balanced,
404                };
405            }
406            SettingsField::WebSearchProvider => {
407                self.web_search_provider = match self.web_search_provider {
408                    None => Some(SearchProvider::Perplexity),
409                    Some(SearchProvider::Tavily) => None,
410                    Some(SearchProvider::Exa) => Some(SearchProvider::Tavily),
411                    Some(SearchProvider::Linkup) => Some(SearchProvider::Exa),
412                    Some(SearchProvider::Perplexity) => Some(SearchProvider::Linkup),
413                };
414            }
415            SettingsField::TavilyApiKey => {}
416            SettingsField::ExaApiKey => {}
417            SettingsField::Save => {}
418        }
419    }
420
421    /// Begin direct numeric input for the current field.
422    pub fn start_edit(&mut self) {
423        match self.current_field() {
424            SettingsField::MaxTokens => {
425                self.editing_number = true;
426                self.edit_buffer = self.max_tokens.to_string();
427            }
428            SettingsField::MaxTurns => {
429                self.editing_number = true;
430                self.edit_buffer = self.max_turns.to_string();
431            }
432            SettingsField::ObservationMask => {
433                self.editing_number = true;
434                self.edit_buffer = format!("{:.2}", self.observation_mask);
435            }
436            SettingsField::ReadMaxLines => {
437                self.editing_number = true;
438                self.edit_buffer = self.read_max_lines.to_string();
439            }
440            SettingsField::SidebarWidth => {
441                self.editing_number = true;
442                self.edit_buffer = self.sidebar_width.to_string();
443            }
444            SettingsField::TavilyApiKey => {
445                self.editing_number = false;
446                self.edit_buffer = self.tavily_api_key.clone();
447            }
448            SettingsField::ExaApiKey => {
449                self.editing_number = false;
450                self.edit_buffer = self.exa_api_key.clone();
451            }
452            _ => {
453                // For enum/bool fields, Enter cycles forward
454                self.cycle_forward();
455            }
456        }
457    }
458
459    pub fn push_char(&mut self, c: char) {
460        if self.editing_number {
461            if c.is_ascii_digit() || c == '.' {
462                self.edit_buffer.push(c);
463            }
464            return;
465        }
466
467        match self.current_field() {
468            SettingsField::TavilyApiKey => {
469                self.tavily_api_key.push(c);
470                self.dirty = true;
471            }
472            SettingsField::ExaApiKey => {
473                self.exa_api_key.push(c);
474                self.dirty = true;
475            }
476            SettingsField::ChosenModels => {
477                if !c.is_control() {
478                    let lower = c.to_ascii_lowercase();
479                    if let Some(next) = self
480                        .model_options
481                        .iter()
482                        .find(|m| m.to_ascii_lowercase().starts_with(lower))
483                    {
484                        self.model = next.clone();
485                    }
486                }
487            }
488            _ => {}
489        }
490    }
491
492    pub fn pop_char(&mut self) {
493        if self.editing_number {
494            self.edit_buffer.pop();
495            return;
496        }
497
498        match self.current_field() {
499            SettingsField::TavilyApiKey => {
500                self.tavily_api_key.pop();
501                self.dirty = true;
502            }
503            SettingsField::ExaApiKey => {
504                self.exa_api_key.pop();
505                self.dirty = true;
506            }
507            _ => {}
508        }
509    }
510
511    /// Commit the edit buffer to the underlying field value.
512    pub fn commit_edit(&mut self) {
513        if !self.editing_number {
514            return;
515        }
516        self.editing_number = false;
517        self.dirty = true;
518        match self.current_field() {
519            SettingsField::MaxTokens => {
520                if let Ok(v) = self.edit_buffer.parse::<u32>() {
521                    self.max_tokens = v.max(1);
522                }
523            }
524            SettingsField::MaxTurns => {
525                if let Ok(v) = self.edit_buffer.parse::<u32>() {
526                    self.max_turns = v.max(1);
527                }
528            }
529            SettingsField::ObservationMask => {
530                if let Ok(v) = self.edit_buffer.parse::<f64>() {
531                    self.observation_mask = v.clamp(0.0, 1.0);
532                }
533            }
534            SettingsField::ReadMaxLines => {
535                if let Ok(v) = self.edit_buffer.parse::<usize>() {
536                    self.read_max_lines = v;
537                }
538            }
539            SettingsField::SidebarWidth => {
540                if let Ok(v) = self.edit_buffer.parse::<u16>() {
541                    self.sidebar_width = v.clamp(20, 80);
542                }
543            }
544            _ => {}
545        }
546        self.edit_buffer.clear();
547    }
548
549    /// Write current settings into a Config for saving and in-session use.
550    pub fn apply_to_config(&self, config: &mut Config) {
551        config.model = Some(self.model.clone());
552        config.enabled_models = if self.chosen_models.is_empty() {
553            None
554        } else {
555            Some(self.chosen_models.clone())
556        };
557        config.theme = Some(self.theme_name.clone());
558        config.thinking = Some(self.thinking_level);
559        config.max_tokens = Some(self.max_tokens);
560        config.max_turns = Some(self.max_turns);
561        config.context = ContextConfig {
562            observation_mask_threshold: self.observation_mask,
563            ..config.context.clone()
564        };
565        config.ui = imp_core::config::UiConfig {
566            sidebar_style: SidebarStyle::Inspector,
567            tool_output: ToolOutputDisplay::Full,
568            tool_output_lines: self.tool_output_lines,
569            read_max_lines: self.read_max_lines,
570            sidebar_width: self.sidebar_width,
571            word_wrap: self.word_wrap,
572            animations: self.animations,
573            hide_tools_in_chat: false,
574            chat_tool_display: ChatToolDisplay::Summary,
575            auto_open_sidebar: self.auto_open_sidebar,
576            sidebar_auto_open_width: self.sidebar_auto_open_width,
577            thinking_lines: self.thinking_lines,
578            streaming_lines: self.streaming_lines,
579            mouse_scroll_lines: self.mouse_scroll_lines,
580            keyboard_scroll_lines: self.keyboard_scroll_lines,
581            mouse_capture: config.ui.mouse_capture,
582            show_timestamps: self.show_timestamps,
583            show_cost: self.show_cost,
584            show_context_usage: self.show_context_usage,
585            notify_on_agent_complete: self.notify_on_agent_complete,
586            continue_policy: self.continue_policy,
587        };
588        config.web = imp_core::tools::web::types::WebConfig {
589            search_provider: self.web_search_provider,
590        };
591    }
592    fn model_is_chosen(&self, model_id: &str) -> bool {
593        self.chosen_models.iter().any(|m| m == model_id)
594    }
595
596    fn toggle_current_model_in_chosen(&mut self) {
597        let model = self.model.clone();
598        if let Some(idx) = self.chosen_models.iter().position(|m| m == &model) {
599            self.chosen_models.remove(idx);
600        } else {
601            self.chosen_models.push(model);
602        }
603    }
604
605    fn chosen_models_summary(&self) -> String {
606        if self.chosen_models.is_empty() {
607            "all models".to_string()
608        } else {
609            format!("{} chosen", self.chosen_models.len())
610        }
611    }
612}
613
614fn theme_options(current: Option<&str>) -> Vec<String> {
615    let mut options = vec!["default".to_string(), "light".to_string()];
616    if let Some(current) = current.filter(|value| !value.trim().is_empty()) {
617        if !options.iter().any(|option| option == current) {
618            options.push(current.to_string());
619        }
620    }
621    options
622}
623
624fn next_thinking(level: ThinkingLevel) -> ThinkingLevel {
625    match level {
626        ThinkingLevel::Off => ThinkingLevel::Low,
627        ThinkingLevel::Minimal => ThinkingLevel::Low,
628        ThinkingLevel::Low => ThinkingLevel::Medium,
629        ThinkingLevel::Medium => ThinkingLevel::High,
630        ThinkingLevel::High => ThinkingLevel::XHigh,
631        ThinkingLevel::XHigh => ThinkingLevel::Off,
632    }
633}
634
635fn prev_thinking(level: ThinkingLevel) -> ThinkingLevel {
636    match level {
637        ThinkingLevel::Off => ThinkingLevel::XHigh,
638        ThinkingLevel::Minimal => ThinkingLevel::Off,
639        ThinkingLevel::Low => ThinkingLevel::Off,
640        ThinkingLevel::Medium => ThinkingLevel::Low,
641        ThinkingLevel::High => ThinkingLevel::Medium,
642        ThinkingLevel::XHigh => ThinkingLevel::High,
643    }
644}
645
646fn thinking_label(level: ThinkingLevel) -> &'static str {
647    match level {
648        ThinkingLevel::Off => "Off",
649        ThinkingLevel::Minimal => "Minimal",
650        ThinkingLevel::Low => "Low",
651        ThinkingLevel::Medium => "Medium",
652        ThinkingLevel::High => "High",
653        ThinkingLevel::XHigh => "XHigh",
654    }
655}
656
657fn animation_label(level: AnimationLevel) -> &'static str {
658    match level {
659        AnimationLevel::None => "none",
660        AnimationLevel::Spinner => "spinner",
661        AnimationLevel::Minimal => "minimal",
662    }
663}
664
665enum SettingsRow {
666    Header,
667    Field(usize),
668    Save,
669}
670
671fn visit_settings_rows(mut visit: impl FnMut(SettingsRow, u16)) {
672    let mut row: u16 = 0;
673    visit(SettingsRow::Header, row);
674    row += 2;
675
676    let sections: &[&[SettingsField]] = &[
677        &[
678            SettingsField::Model,
679            SettingsField::ChosenModels,
680            SettingsField::Theme,
681            SettingsField::ThinkingLevel,
682            SettingsField::MaxTokens,
683            SettingsField::MaxTurns,
684        ],
685        &[SettingsField::ObservationMask],
686        &[
687            SettingsField::ReadMaxLines,
688            SettingsField::SidebarWidth,
689            SettingsField::WordWrap,
690            SettingsField::Animations,
691            SettingsField::AutoOpenSidebar,
692            SettingsField::SidebarAutoOpenWidth,
693            SettingsField::ThinkingLines,
694            SettingsField::StreamingLines,
695            SettingsField::MouseScrollLines,
696            SettingsField::KeyboardScrollLines,
697            SettingsField::ShowTimestamps,
698            SettingsField::ShowCost,
699            SettingsField::ShowContextUsage,
700            SettingsField::NotifyOnAgentComplete,
701            SettingsField::ContinuePolicy,
702            SettingsField::WebSearchProvider,
703            SettingsField::TavilyApiKey,
704            SettingsField::ExaApiKey,
705        ],
706    ];
707
708    for (section_idx, section) in sections.iter().enumerate() {
709        if section_idx > 0 {
710            row += 1;
711        }
712        for field in *section {
713            visit(SettingsRow::Field(field_index(*field)), row);
714            row += 1;
715        }
716    }
717
718    row += 1;
719    visit(SettingsRow::Save, row);
720}
721
722fn total_settings_rows() -> u16 {
723    let mut total = 0;
724    visit_settings_rows(|_, row| {
725        total = row.saturating_add(1);
726    });
727    total
728}
729
730fn selected_settings_row(selected: usize) -> u16 {
731    let selected = selected.min(FIELDS.len().saturating_sub(1));
732    let mut selected_row = 0;
733    visit_settings_rows(|entry, row| match entry {
734        SettingsRow::Field(field_idx) if field_idx == selected => selected_row = row,
735        SettingsRow::Save if selected == FIELDS.len().saturating_sub(1) => selected_row = row,
736        _ => {}
737    });
738    selected_row
739}
740
741fn settings_scroll_offset(selected: usize, visible_rows: u16) -> u16 {
742    if visible_rows == 0 {
743        return 0;
744    }
745
746    let total_rows = total_settings_rows();
747    if total_rows <= visible_rows {
748        return 0;
749    }
750
751    let selected_row = selected_settings_row(selected);
752    let desired = selected_row.saturating_sub(visible_rows.saturating_sub(1));
753    desired.min(total_rows.saturating_sub(visible_rows))
754}
755
756fn scrolled_screen_y(inner: Rect, logical_row: u16, scroll_offset: u16) -> Option<u16> {
757    if logical_row < scroll_offset {
758        return None;
759    }
760
761    let visible_row = logical_row - scroll_offset;
762    if visible_row >= inner.height {
763        return None;
764    }
765
766    Some(inner.y + visible_row)
767}
768
769/// Settings overlay widget.
770pub struct SettingsView<'a> {
771    state: &'a SettingsState,
772    theme: &'a Theme,
773}
774
775impl<'a> SettingsView<'a> {
776    pub fn new(state: &'a SettingsState, theme: &'a Theme) -> Self {
777        Self { state, theme }
778    }
779}
780
781impl Widget for SettingsView<'_> {
782    fn render(self, area: Rect, buf: &mut Buffer) {
783        if area.height < 10 || area.width < 30 {
784            return;
785        }
786
787        Clear.render(area, buf);
788
789        let title = if self.state.dirty {
790            " Settings * "
791        } else {
792            " Settings "
793        };
794        let block = Block::default()
795            .title(title)
796            .borders(Borders::ALL)
797            .border_style(self.theme.accent_style());
798        let inner = block.inner(area);
799        block.render(area, buf);
800
801        let total_rows = total_settings_rows();
802        let scroll_offset = settings_scroll_offset(self.state.normalized_selected(), inner.height);
803
804        let mut row: u16 = 0;
805
806        let header = Line::from(Span::styled(
807            "  ↑/↓ move  ←/→ change  Enter edit  Esc close",
808            self.theme.muted_style(),
809        ));
810        if let Some(y) = scrolled_screen_y(inner, row, scroll_offset) {
811            buf.set_line(inner.x, y, &header, inner.width);
812        }
813        row += 2;
814
815        render_field(
816            self.state,
817            self.theme,
818            buf,
819            inner,
820            scroll_offset,
821            &mut row,
822            field_index(SettingsField::Model),
823            "Model",
824            &self.state.model,
825            "← →",
826        );
827
828        let chosen_hint = if self.state.model_is_chosen(&self.state.model) {
829            "← → toggle current"
830        } else {
831            "← → add current"
832        };
833        let chosen_summary = self.state.chosen_models_summary();
834        render_field(
835            self.state,
836            self.theme,
837            buf,
838            inner,
839            scroll_offset,
840            &mut row,
841            1,
842            "Chosen models",
843            &chosen_summary,
844            chosen_hint,
845        );
846
847        render_field(
848            self.state,
849            self.theme,
850            buf,
851            inner,
852            scroll_offset,
853            &mut row,
854            field_index(SettingsField::Theme),
855            "Color theme",
856            &self.state.theme_name,
857            "← → (UI colors)",
858        );
859
860        render_field(
861            self.state,
862            self.theme,
863            buf,
864            inner,
865            scroll_offset,
866            &mut row,
867            field_index(SettingsField::ThinkingLevel),
868            "Thinking level",
869            thinking_label(self.state.thinking_level),
870            "← →",
871        );
872
873        let max_tokens_val = if self.state.editing_number
874            && self.state.current_field() == SettingsField::MaxTokens
875        {
876            format!("{}▎", self.state.edit_buffer)
877        } else {
878            self.state.max_tokens.to_string()
879        };
880        render_field(
881            self.state,
882            self.theme,
883            buf,
884            inner,
885            scroll_offset,
886            &mut row,
887            field_index(SettingsField::MaxTokens),
888            "Max tokens",
889            &max_tokens_val,
890            "← → / type",
891        );
892
893        let max_turns_val =
894            if self.state.editing_number && self.state.current_field() == SettingsField::MaxTurns {
895                format!("{}▎", self.state.edit_buffer)
896            } else {
897                self.state.max_turns.to_string()
898            };
899        render_field(
900            self.state,
901            self.theme,
902            buf,
903            inner,
904            scroll_offset,
905            &mut row,
906            field_index(SettingsField::MaxTurns),
907            "Max turns",
908            &max_turns_val,
909            "← → / type",
910        );
911
912        row += 1;
913
914        let obs_val = if self.state.editing_number
915            && self.state.current_field() == SettingsField::ObservationMask
916        {
917            format!("{}▎", self.state.edit_buffer)
918        } else {
919            format!("{:.0}%", self.state.observation_mask * 100.0)
920        };
921        render_field(
922            self.state,
923            self.theme,
924            buf,
925            inner,
926            scroll_offset,
927            &mut row,
928            field_index(SettingsField::ObservationMask),
929            "Observation mask",
930            &obs_val,
931            "← →",
932        );
933
934        row += 1;
935
936        let rml_val = if self.state.editing_number
937            && self.state.current_field() == SettingsField::ReadMaxLines
938        {
939            format!("{}▎", self.state.edit_buffer)
940        } else {
941            self.state.read_max_lines.to_string()
942        };
943        render_field(
944            self.state,
945            self.theme,
946            buf,
947            inner,
948            scroll_offset,
949            &mut row,
950            field_index(SettingsField::ReadMaxLines),
951            "Read max lines",
952            &rml_val,
953            "← → / type (0 = no limit)",
954        );
955
956        let sw_val = if self.state.editing_number
957            && self.state.current_field() == SettingsField::SidebarWidth
958        {
959            format!("{}▎", self.state.edit_buffer)
960        } else {
961            format!("{}%", self.state.sidebar_width)
962        };
963        render_field(
964            self.state,
965            self.theme,
966            buf,
967            inner,
968            scroll_offset,
969            &mut row,
970            field_index(SettingsField::SidebarWidth),
971            "Inspector width",
972            &sw_val,
973            "← → / type",
974        );
975
976        render_field(
977            self.state,
978            self.theme,
979            buf,
980            inner,
981            scroll_offset,
982            &mut row,
983            field_index(SettingsField::WordWrap),
984            "Word wrap",
985            if self.state.word_wrap { "on" } else { "off" },
986            "← →",
987        );
988
989        render_field(
990            self.state,
991            self.theme,
992            buf,
993            inner,
994            scroll_offset,
995            &mut row,
996            field_index(SettingsField::Animations),
997            "Animations",
998            animation_label(self.state.animations),
999            "← →",
1000        );
1001
1002        render_field(
1003            self.state,
1004            self.theme,
1005            buf,
1006            inner,
1007            scroll_offset,
1008            &mut row,
1009            field_index(SettingsField::AutoOpenSidebar),
1010            "Auto-open sidebar",
1011            if self.state.auto_open_sidebar {
1012                "on"
1013            } else {
1014                "off"
1015            },
1016            "← →",
1017        );
1018
1019        let sao_val = if self.state.editing_number
1020            && self.state.current_field() == SettingsField::SidebarAutoOpenWidth
1021        {
1022            format!("{}▎", self.state.edit_buffer)
1023        } else {
1024            self.state.sidebar_auto_open_width.to_string()
1025        };
1026        render_field(
1027            self.state,
1028            self.theme,
1029            buf,
1030            inner,
1031            scroll_offset,
1032            &mut row,
1033            field_index(SettingsField::SidebarAutoOpenWidth),
1034            "Auto-open width",
1035            &sao_val,
1036            "← → / type",
1037        );
1038
1039        let thinking_lines_val = if self.state.editing_number
1040            && self.state.current_field() == SettingsField::ThinkingLines
1041        {
1042            format!("{}▎", self.state.edit_buffer)
1043        } else {
1044            self.state.thinking_lines.to_string()
1045        };
1046        render_field(
1047            self.state,
1048            self.theme,
1049            buf,
1050            inner,
1051            scroll_offset,
1052            &mut row,
1053            field_index(SettingsField::ThinkingLines),
1054            "Thinking lines",
1055            &thinking_lines_val,
1056            "← → / type",
1057        );
1058
1059        let streaming_lines_val = if self.state.editing_number
1060            && self.state.current_field() == SettingsField::StreamingLines
1061        {
1062            format!("{}▎", self.state.edit_buffer)
1063        } else {
1064            self.state.streaming_lines.to_string()
1065        };
1066        render_field(
1067            self.state,
1068            self.theme,
1069            buf,
1070            inner,
1071            scroll_offset,
1072            &mut row,
1073            field_index(SettingsField::StreamingLines),
1074            "Streaming lines",
1075            &streaming_lines_val,
1076            "← → / type",
1077        );
1078
1079        let mouse_scroll_val = if self.state.editing_number
1080            && self.state.current_field() == SettingsField::MouseScrollLines
1081        {
1082            format!("{}▎", self.state.edit_buffer)
1083        } else {
1084            self.state.mouse_scroll_lines.to_string()
1085        };
1086        render_field(
1087            self.state,
1088            self.theme,
1089            buf,
1090            inner,
1091            scroll_offset,
1092            &mut row,
1093            field_index(SettingsField::MouseScrollLines),
1094            "Mouse scroll",
1095            &mouse_scroll_val,
1096            "← → / type",
1097        );
1098
1099        let keyboard_scroll_val = if self.state.editing_number
1100            && self.state.current_field() == SettingsField::KeyboardScrollLines
1101        {
1102            format!("{}▎", self.state.edit_buffer)
1103        } else {
1104            self.state.keyboard_scroll_lines.to_string()
1105        };
1106        render_field(
1107            self.state,
1108            self.theme,
1109            buf,
1110            inner,
1111            scroll_offset,
1112            &mut row,
1113            field_index(SettingsField::KeyboardScrollLines),
1114            "Keyboard scroll",
1115            &keyboard_scroll_val,
1116            "← → / type",
1117        );
1118
1119        render_field(
1120            self.state,
1121            self.theme,
1122            buf,
1123            inner,
1124            scroll_offset,
1125            &mut row,
1126            field_index(SettingsField::ShowTimestamps),
1127            "Show timestamps",
1128            if self.state.show_timestamps {
1129                "on"
1130            } else {
1131                "off"
1132            },
1133            "← →",
1134        );
1135        render_field(
1136            self.state,
1137            self.theme,
1138            buf,
1139            inner,
1140            scroll_offset,
1141            &mut row,
1142            field_index(SettingsField::ShowCost),
1143            "Show cost",
1144            if self.state.show_cost { "on" } else { "off" },
1145            "← →",
1146        );
1147        render_field(
1148            self.state,
1149            self.theme,
1150            buf,
1151            inner,
1152            scroll_offset,
1153            &mut row,
1154            field_index(SettingsField::ShowContextUsage),
1155            "Show context",
1156            if self.state.show_context_usage {
1157                "on"
1158            } else {
1159                "off"
1160            },
1161            "← →",
1162        );
1163
1164        render_field(
1165            self.state,
1166            self.theme,
1167            buf,
1168            inner,
1169            scroll_offset,
1170            &mut row,
1171            field_index(SettingsField::NotifyOnAgentComplete),
1172            "Bell on done",
1173            if self.state.notify_on_agent_complete {
1174                "on"
1175            } else {
1176                "off"
1177            },
1178            "← →",
1179        );
1180
1181        render_field(
1182            self.state,
1183            self.theme,
1184            buf,
1185            inner,
1186            scroll_offset,
1187            &mut row,
1188            field_index(SettingsField::ContinuePolicy),
1189            "Auto-continue",
1190            match self.state.continue_policy {
1191                ContinuePolicy::Disabled => "disabled",
1192                ContinuePolicy::Conservative => "conservative",
1193                ContinuePolicy::Balanced => "balanced",
1194                ContinuePolicy::Aggressive => "aggressive",
1195            },
1196            "← →",
1197        );
1198
1199        render_field(
1200            self.state,
1201            self.theme,
1202            buf,
1203            inner,
1204            scroll_offset,
1205            &mut row,
1206            field_index(SettingsField::WebSearchProvider),
1207            "Web provider",
1208            match self.state.web_search_provider {
1209                None => "auto",
1210                Some(SearchProvider::Tavily) => "tavily",
1211                Some(SearchProvider::Exa) => "exa",
1212                Some(SearchProvider::Linkup) => "linkup",
1213                Some(SearchProvider::Perplexity) => "perplexity",
1214            },
1215            "← →",
1216        );
1217
1218        let tavily_val = if self.state.tavily_api_key.is_empty() {
1219            if self.state.tavily_configured {
1220                "configured (press Enter to replace)".to_string()
1221            } else {
1222                "not set".to_string()
1223            }
1224        } else {
1225            format!(
1226                "{}▎",
1227                "•".repeat(self.state.tavily_api_key.chars().count().max(1))
1228            )
1229        };
1230        render_field(
1231            self.state,
1232            self.theme,
1233            buf,
1234            inner,
1235            scroll_offset,
1236            &mut row,
1237            field_index(SettingsField::TavilyApiKey),
1238            "Tavily API key",
1239            &tavily_val,
1240            "Enter to edit",
1241        );
1242
1243        let exa_val = if self.state.exa_api_key.is_empty() {
1244            if self.state.exa_configured {
1245                "configured (press Enter to replace)".to_string()
1246            } else {
1247                "not set".to_string()
1248            }
1249        } else {
1250            format!(
1251                "{}▎",
1252                "•".repeat(self.state.exa_api_key.chars().count().max(1))
1253            )
1254        };
1255        render_field(
1256            self.state,
1257            self.theme,
1258            buf,
1259            inner,
1260            scroll_offset,
1261            &mut row,
1262            field_index(SettingsField::ExaApiKey),
1263            "Exa API key",
1264            &exa_val,
1265            "Enter to edit",
1266        );
1267        row += 1;
1268
1269        if let Some(y) = scrolled_screen_y(inner, row, scroll_offset) {
1270            let is_save = self.state.normalized_selected() == FIELDS.len() - 1;
1271            let save_style = if is_save {
1272                Style::default()
1273                    .fg(self.theme.accent)
1274                    .add_modifier(Modifier::BOLD)
1275            } else {
1276                self.theme.muted_style()
1277            };
1278            let marker = if is_save { "▸ " } else { "  " };
1279            let dirty_hint = if self.state.dirty {
1280                " (unsaved changes)"
1281            } else {
1282                ""
1283            };
1284            let line = Line::from(vec![
1285                Span::styled(marker, self.theme.accent_style()),
1286                Span::styled("[ Save to config.toml ]", save_style),
1287                Span::styled(dirty_hint, self.theme.warning_style()),
1288            ]);
1289            buf.set_line(inner.x, y, &line, inner.width);
1290        }
1291
1292        if scroll_offset > 0 {
1293            let hint = Line::from(Span::styled("↑ more", self.theme.muted_style()));
1294            buf.set_line(inner.x + inner.width.saturating_sub(7), inner.y, &hint, 7);
1295        }
1296        if scroll_offset + inner.height < total_rows {
1297            let hint = Line::from(Span::styled("↓ more", self.theme.muted_style()));
1298            let y = inner.y + inner.height.saturating_sub(1);
1299            buf.set_line(inner.x + inner.width.saturating_sub(7), y, &hint, 7);
1300        }
1301    }
1302}
1303
1304/// Render one settings field row.
1305#[allow(clippy::too_many_arguments)]
1306fn render_field(
1307    state: &SettingsState,
1308    theme: &Theme,
1309    buf: &mut Buffer,
1310    inner: Rect,
1311    scroll_offset: u16,
1312    row: &mut u16,
1313    field_idx: usize,
1314    label: &str,
1315    value: &str,
1316    hint: &str,
1317) {
1318    let logical_row = *row;
1319    let Some(screen_y) = scrolled_screen_y(inner, logical_row, scroll_offset) else {
1320        *row += 1;
1321        return;
1322    };
1323
1324    let is_selected = field_idx == state.normalized_selected();
1325    let marker = if is_selected { "▸ " } else { "  " };
1326
1327    let label_style = if is_selected {
1328        theme.selected_style()
1329    } else {
1330        Style::default()
1331    };
1332    let value_style = if is_selected {
1333        Style::default()
1334            .fg(theme.accent)
1335            .add_modifier(Modifier::BOLD)
1336    } else {
1337        Style::default()
1338    };
1339
1340    let label_width = 22;
1341    let line = Line::from(vec![
1342        Span::styled(marker, theme.accent_style()),
1343        Span::styled(format!("{label:<label_width$}"), label_style),
1344        Span::styled(value, value_style),
1345        Span::raw("  "),
1346        Span::styled(hint, theme.muted_style()),
1347    ]);
1348    buf.set_line(inner.x, screen_y, &line, inner.width);
1349    *row += 1;
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use super::*;
1355    use imp_core::config::Config;
1356    use imp_llm::auth::AuthStore;
1357    use imp_llm::model::ModelRegistry;
1358
1359    #[test]
1360    fn applying_settings_forces_primary_inspector_display_model() {
1361        let registry = ModelRegistry::with_builtins();
1362        let models = registry.list().to_vec();
1363        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1364        let mut config = Config::default();
1365        let state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1366
1367        state.apply_to_config(&mut config);
1368
1369        assert_eq!(config.ui.sidebar_style, SidebarStyle::Inspector);
1370        assert_eq!(config.ui.tool_output, ToolOutputDisplay::Full);
1371        assert_eq!(config.ui.chat_tool_display, ChatToolDisplay::Summary);
1372        assert!(!config.ui.hide_tools_in_chat);
1373    }
1374
1375    #[test]
1376    fn save_field_scrolls_into_view_on_short_panels() {
1377        assert_eq!(selected_settings_row(FIELDS.len() - 1), 30);
1378        assert_eq!(total_settings_rows(), 31);
1379        assert_eq!(settings_scroll_offset(FIELDS.len() - 1, 10), 21);
1380    }
1381
1382    #[test]
1383    fn custom_theme_value_is_selectable_and_cycles() {
1384        let registry = ModelRegistry::with_builtins();
1385        let models = registry.list().to_vec();
1386        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1387        let config = Config {
1388            theme: Some("custom-highlighter".into()),
1389            ..Config::default()
1390        };
1391        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1392
1393        assert_eq!(state.theme_name, "custom-highlighter");
1394        assert!(state
1395            .theme_options
1396            .iter()
1397            .any(|theme| theme == "custom-highlighter"));
1398
1399        state.selected = field_index(SettingsField::Theme);
1400        state.cycle_forward();
1401        assert_eq!(state.theme_name, "default");
1402        state.cycle_backward();
1403        assert_eq!(state.theme_name, "custom-highlighter");
1404    }
1405
1406    #[test]
1407    fn top_fields_do_not_scroll_when_visible() {
1408        assert_eq!(selected_settings_row(0), 2);
1409        assert_eq!(settings_scroll_offset(0, 10), 0);
1410        assert_eq!(settings_scroll_offset(5, 10), 0);
1411    }
1412
1413    #[test]
1414    fn current_field_clamps_stale_selection() {
1415        let registry = ModelRegistry::with_builtins();
1416        let models = registry.list().to_vec();
1417        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1418        let state = SettingsState {
1419            selected: usize::MAX,
1420            ..SettingsState::new(&Config::default(), &models[0].id, &models, &auth_store)
1421        };
1422
1423        assert_eq!(state.current_field(), SettingsField::Save);
1424    }
1425
1426    #[test]
1427    fn cycle_model_is_safe_with_empty_model_options() {
1428        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1429        let mut state = SettingsState::new(&Config::default(), "custom-model", &[], &auth_store);
1430        state.selected = 0;
1431        state.model_options.clear();
1432
1433        state.cycle_forward();
1434        state.cycle_backward();
1435
1436        assert_eq!(state.model, "custom-model");
1437    }
1438
1439    #[test]
1440    fn chosen_models_round_trip_into_config() {
1441        let registry = ModelRegistry::with_builtins();
1442        let models = registry.list().to_vec();
1443        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1444        let mut config = Config::default();
1445        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1446
1447        state.selected = 1;
1448        state.cycle_forward();
1449        assert_eq!(state.chosen_models, vec![models[0].id.clone()]);
1450
1451        state.apply_to_config(&mut config);
1452        assert_eq!(config.enabled_models, Some(vec![models[0].id.clone()]));
1453    }
1454
1455    #[test]
1456    fn bell_setting_round_trips_into_config() {
1457        let registry = ModelRegistry::with_builtins();
1458        let models = registry.list().to_vec();
1459        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1460        let mut config = Config::default();
1461        let state = SettingsState {
1462            notify_on_agent_complete: false,
1463            ..SettingsState::new(&config, &models[0].id, &models, &auth_store)
1464        };
1465
1466        state.apply_to_config(&mut config);
1467        assert!(!config.ui.notify_on_agent_complete);
1468    }
1469
1470    #[test]
1471    fn continue_policy_round_trips_into_config() {
1472        let registry = ModelRegistry::with_builtins();
1473        let models = registry.list().to_vec();
1474        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1475        let mut config = Config::default();
1476        let state = SettingsState {
1477            continue_policy: ContinuePolicy::Balanced,
1478            ..SettingsState::new(&config, &models[0].id, &models, &auth_store)
1479        };
1480
1481        state.apply_to_config(&mut config);
1482        assert_eq!(config.ui.continue_policy, ContinuePolicy::Balanced);
1483    }
1484
1485    #[test]
1486    fn empty_chosen_models_means_all_models() {
1487        let registry = ModelRegistry::with_builtins();
1488        let models = registry.list().to_vec();
1489        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1490        let mut config = Config::default();
1491        let state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1492
1493        state.apply_to_config(&mut config);
1494        assert_eq!(config.enabled_models, None);
1495    }
1496}