Skip to main content

imp_tui/views/
settings.rs

1use imp_core::config::{
2    AnimationLevel, ChatToolDisplay, Config, ContextConfig, ContinuePolicy, ManaConfig,
3    ManaRunConfig, ManaScopePreference, SidebarStyle, 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    ImproveAutoTurnBudget,
43    LoopTurnBudget,
44    WebSearchProvider,
45    TavilyApiKey,
46    ExaApiKey,
47    ManaScope,
48    ManaAutoCommit,
49    ManaAutoCloseParent,
50    ManaVerifyTimeout,
51    ManaRunBackground,
52    ManaMaxWorkers,
53    ManaReviewAfterRun,
54    ManaContinueAfterFailure,
55    Save,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum SettingsTab {
60    General,
61    Model,
62    Ui,
63    Security,
64    Web,
65    Mana,
66}
67
68const SETTINGS_TABS: &[SettingsTab] = &[
69    SettingsTab::General,
70    SettingsTab::Model,
71    SettingsTab::Ui,
72    SettingsTab::Security,
73    SettingsTab::Web,
74    SettingsTab::Mana,
75];
76
77const GENERAL_FIELDS: &[SettingsField] = &[
78    SettingsField::Theme,
79    SettingsField::MaxTurns,
80    SettingsField::NotifyOnAgentComplete,
81    SettingsField::ContinuePolicy,
82    SettingsField::ImproveAutoTurnBudget,
83    SettingsField::LoopTurnBudget,
84];
85
86const MODEL_FIELDS: &[SettingsField] = &[
87    SettingsField::Model,
88    SettingsField::ChosenModels,
89    SettingsField::ThinkingLevel,
90    SettingsField::MaxTokens,
91    SettingsField::ObservationMask,
92];
93
94const UI_FIELDS: &[SettingsField] = &[
95    SettingsField::ReadMaxLines,
96    SettingsField::SidebarWidth,
97    SettingsField::WordWrap,
98    SettingsField::Animations,
99    SettingsField::AutoOpenSidebar,
100    SettingsField::SidebarAutoOpenWidth,
101    SettingsField::ThinkingLines,
102    SettingsField::StreamingLines,
103    SettingsField::MouseScrollLines,
104    SettingsField::KeyboardScrollLines,
105    SettingsField::ShowTimestamps,
106    SettingsField::ShowCost,
107    SettingsField::ShowContextUsage,
108];
109
110const SECURITY_FIELDS: &[SettingsField] = &[];
111
112const WEB_FIELDS: &[SettingsField] = &[
113    SettingsField::WebSearchProvider,
114    SettingsField::TavilyApiKey,
115    SettingsField::ExaApiKey,
116];
117
118const MANA_FIELDS: &[SettingsField] = &[
119    SettingsField::ManaScope,
120    SettingsField::ManaAutoCommit,
121    SettingsField::ManaAutoCloseParent,
122    SettingsField::ManaVerifyTimeout,
123    SettingsField::ManaRunBackground,
124    SettingsField::ManaMaxWorkers,
125    SettingsField::ManaReviewAfterRun,
126    SettingsField::ManaContinueAfterFailure,
127];
128
129const FIELDS: &[SettingsField] = &[
130    SettingsField::Model,
131    SettingsField::ChosenModels,
132    SettingsField::Theme,
133    SettingsField::ThinkingLevel,
134    SettingsField::MaxTokens,
135    SettingsField::MaxTurns,
136    SettingsField::ObservationMask,
137    SettingsField::ReadMaxLines,
138    SettingsField::SidebarWidth,
139    SettingsField::WordWrap,
140    SettingsField::Animations,
141    SettingsField::AutoOpenSidebar,
142    SettingsField::SidebarAutoOpenWidth,
143    SettingsField::ThinkingLines,
144    SettingsField::StreamingLines,
145    SettingsField::MouseScrollLines,
146    SettingsField::KeyboardScrollLines,
147    SettingsField::ShowTimestamps,
148    SettingsField::ShowCost,
149    SettingsField::ShowContextUsage,
150    SettingsField::NotifyOnAgentComplete,
151    SettingsField::ContinuePolicy,
152    SettingsField::ImproveAutoTurnBudget,
153    SettingsField::LoopTurnBudget,
154    SettingsField::WebSearchProvider,
155    SettingsField::TavilyApiKey,
156    SettingsField::ExaApiKey,
157    SettingsField::ManaScope,
158    SettingsField::ManaAutoCommit,
159    SettingsField::ManaAutoCloseParent,
160    SettingsField::ManaVerifyTimeout,
161    SettingsField::ManaRunBackground,
162    SettingsField::ManaMaxWorkers,
163    SettingsField::ManaReviewAfterRun,
164    SettingsField::ManaContinueAfterFailure,
165    SettingsField::Save,
166];
167
168impl SettingsTab {
169    fn label(self) -> &'static str {
170        match self {
171            SettingsTab::General => "General",
172            SettingsTab::Model => "Model",
173            SettingsTab::Ui => "UI",
174            SettingsTab::Security => "Security",
175            SettingsTab::Web => "Web",
176            SettingsTab::Mana => "Mana",
177        }
178    }
179
180    fn fields(self) -> &'static [SettingsField] {
181        match self {
182            SettingsTab::General => GENERAL_FIELDS,
183            SettingsTab::Model => MODEL_FIELDS,
184            SettingsTab::Ui => UI_FIELDS,
185            SettingsTab::Security => SECURITY_FIELDS,
186            SettingsTab::Web => WEB_FIELDS,
187            SettingsTab::Mana => MANA_FIELDS,
188        }
189    }
190
191    fn empty_message(self) -> Option<&'static str> {
192        match self {
193            SettingsTab::Security => Some("Security ask/act thresholds are coming soon."),
194            SettingsTab::Mana => None,
195            _ => None,
196        }
197    }
198}
199
200fn field_index(field: SettingsField) -> usize {
201    FIELDS
202        .iter()
203        .position(|candidate| *candidate == field)
204        .expect("settings field is registered")
205}
206
207/// State for the settings overlay.
208#[derive(Debug, Clone)]
209pub struct SettingsState {
210    pub selected: usize,
211    pub tab: SettingsTab,
212    pub model: String,
213    pub model_options: Vec<String>,
214    pub chosen_models: Vec<String>,
215    pub theme_name: String,
216    pub theme_options: Vec<String>,
217    pub thinking_level: ThinkingLevel,
218    pub max_tokens: u32,
219    pub max_turns: u32,
220    pub observation_mask: f64,
221    pub sidebar_style: SidebarStyle,
222    pub tool_output: ToolOutputDisplay,
223    pub tool_output_lines: usize,
224    pub read_max_lines: usize,
225    pub sidebar_width: u16,
226    pub word_wrap: bool,
227    pub animations: AnimationLevel,
228    pub chat_tool_display: ChatToolDisplay,
229    pub auto_open_sidebar: bool,
230    pub sidebar_auto_open_width: u16,
231    pub thinking_lines: usize,
232    pub streaming_lines: usize,
233    pub mouse_scroll_lines: usize,
234    pub keyboard_scroll_lines: usize,
235    pub show_timestamps: bool,
236    pub show_cost: bool,
237    pub show_context_usage: bool,
238    pub notify_on_agent_complete: bool,
239    pub continue_policy: ContinuePolicy,
240    pub improve_auto_turn_budget: u32,
241    pub loop_turn_budget: u32,
242    pub web_search_provider: Option<SearchProvider>,
243    pub mana_scope: ManaScopePreference,
244    pub mana_auto_commit: bool,
245    pub mana_auto_close_parent: bool,
246    pub mana_verify_timeout: u64,
247    pub mana_run_background: bool,
248    pub mana_max_workers: u32,
249    pub mana_review_after_run: bool,
250    pub mana_continue_after_failure: bool,
251    pub tavily_api_key: String,
252    pub exa_api_key: String,
253    pub tavily_configured: bool,
254    pub exa_configured: bool,
255    pub editing_number: bool,
256    pub edit_buffer: String,
257    pub dirty: bool,
258}
259
260impl SettingsState {
261    fn normalized_selected(&self) -> usize {
262        field_index(self.current_field())
263    }
264
265    fn selected_tab_index(&self) -> usize {
266        SETTINGS_TABS
267            .iter()
268            .position(|candidate| *candidate == self.tab)
269            .unwrap_or(0)
270    }
271
272    fn visible_fields(&self) -> &'static [SettingsField] {
273        self.tab.fields()
274    }
275
276    fn visible_selection(&self) -> Vec<SettingsField> {
277        let mut fields = self.visible_fields().to_vec();
278        fields.push(SettingsField::Save);
279        fields
280    }
281
282    pub fn switch_tab_forward(&mut self) {
283        self.commit_edit();
284        let next = (self.selected_tab_index() + 1) % SETTINGS_TABS.len();
285        self.tab = SETTINGS_TABS[next];
286        self.selected = field_index(
287            self.visible_fields()
288                .first()
289                .copied()
290                .unwrap_or(SettingsField::Save),
291        );
292    }
293
294    pub fn switch_tab_backward(&mut self) {
295        self.commit_edit();
296        let idx = self.selected_tab_index();
297        let prev = if idx == 0 {
298            SETTINGS_TABS.len() - 1
299        } else {
300            idx - 1
301        };
302        self.tab = SETTINGS_TABS[prev];
303        self.selected = field_index(
304            self.visible_fields()
305                .first()
306                .copied()
307                .unwrap_or(SettingsField::Save),
308        );
309    }
310
311    pub fn new(
312        config: &Config,
313        model_name: &str,
314        models: &[ModelMeta],
315        auth_store: &AuthStore,
316    ) -> Self {
317        Self {
318            selected: field_index(SettingsField::Theme),
319            tab: SettingsTab::General,
320            model: model_name.to_string(),
321            model_options: models.iter().map(|m| m.id.clone()).collect(),
322            chosen_models: config.enabled_models.clone().unwrap_or_default(),
323            theme_name: config.theme.clone().unwrap_or_else(|| "default".into()),
324            theme_options: theme_options(config.theme.as_deref()),
325            thinking_level: config.thinking.unwrap_or(ThinkingLevel::Medium),
326            max_tokens: config.max_tokens.unwrap_or(4096),
327            max_turns: config.max_turns.unwrap_or(100),
328            observation_mask: config.context.observation_mask_threshold,
329            sidebar_style: config.ui.sidebar_style,
330            tool_output: config.ui.tool_output,
331            tool_output_lines: config.ui.tool_output_lines,
332            read_max_lines: config.ui.read_max_lines,
333            sidebar_width: config.ui.sidebar_width,
334            word_wrap: config.ui.word_wrap,
335            animations: config.ui.animations,
336            chat_tool_display: config.ui.effective_chat_tool_display(),
337            auto_open_sidebar: config.ui.auto_open_sidebar,
338            sidebar_auto_open_width: config.ui.sidebar_auto_open_width,
339            thinking_lines: config.ui.thinking_lines,
340            streaming_lines: config.ui.streaming_lines,
341            mouse_scroll_lines: config.ui.mouse_scroll_lines,
342            keyboard_scroll_lines: config.ui.keyboard_scroll_lines,
343            show_timestamps: config.ui.show_timestamps,
344            show_cost: config.ui.show_cost,
345            show_context_usage: config.ui.show_context_usage,
346            notify_on_agent_complete: config.ui.notify_on_agent_complete,
347            continue_policy: config.ui.continue_policy,
348            improve_auto_turn_budget: config.ui.improve_auto_turn_budget,
349            loop_turn_budget: config.ui.loop_turn_budget,
350            web_search_provider: config.web.search_provider,
351            mana_scope: config.mana.scope,
352            mana_auto_commit: config.mana.auto_commit,
353            mana_auto_close_parent: config.mana.auto_close_parent,
354            mana_verify_timeout: config.mana.verify_timeout.unwrap_or(0),
355            mana_run_background: config.mana.run.background,
356            mana_max_workers: config.mana.run.max_workers,
357            mana_review_after_run: config.mana.run.review_after_run,
358            mana_continue_after_failure: config.mana.run.continue_after_failure,
359            tavily_api_key: String::new(),
360            exa_api_key: String::new(),
361            tavily_configured: auth_store.stored.contains_key("tavily")
362                || std::env::var("TAVILY_API_KEY").is_ok(),
363            exa_configured: auth_store.stored.contains_key("exa")
364                || std::env::var("EXA_API_KEY").is_ok(),
365            editing_number: false,
366            edit_buffer: String::new(),
367            dirty: false,
368        }
369    }
370
371    pub fn current_field(&self) -> SettingsField {
372        let selected = FIELDS
373            .get(self.selected)
374            .copied()
375            .unwrap_or(SettingsField::Save);
376        if selected == SettingsField::Save || self.visible_fields().contains(&selected) {
377            return selected;
378        }
379        self.visible_fields()
380            .first()
381            .copied()
382            .unwrap_or(SettingsField::Save)
383    }
384
385    pub fn move_up(&mut self) {
386        self.commit_edit();
387        let fields = self.visible_selection();
388        let current = self.current_field();
389        let pos = fields
390            .iter()
391            .position(|field| *field == current)
392            .unwrap_or(0);
393        if pos > 0 {
394            self.selected = field_index(fields[pos - 1]);
395        }
396    }
397
398    pub fn move_down(&mut self) {
399        self.commit_edit();
400        let fields = self.visible_selection();
401        let current = self.current_field();
402        let pos = fields
403            .iter()
404            .position(|field| *field == current)
405            .unwrap_or(0);
406        if pos + 1 < fields.len() {
407            self.selected = field_index(fields[pos + 1]);
408        }
409    }
410
411    /// Cycle the current field's value forward.
412    pub fn cycle_forward(&mut self) {
413        self.dirty = true;
414        match self.current_field() {
415            SettingsField::Model => {
416                if !self.model_options.is_empty() {
417                    if let Some(idx) = self.model_options.iter().position(|m| *m == self.model) {
418                        let next = (idx + 1) % self.model_options.len();
419                        self.model = self.model_options[next].clone();
420                    }
421                }
422            }
423            SettingsField::ChosenModels => {
424                self.toggle_current_model_in_chosen();
425            }
426            SettingsField::Theme => {
427                if !self.theme_options.is_empty() {
428                    let idx = self
429                        .theme_options
430                        .iter()
431                        .position(|t| *t == self.theme_name)
432                        .unwrap_or(0);
433                    let next = (idx + 1) % self.theme_options.len();
434                    self.theme_name = self.theme_options[next].clone();
435                }
436            }
437            SettingsField::ThinkingLevel => {
438                self.thinking_level = next_thinking(self.thinking_level);
439            }
440            SettingsField::MaxTokens => {
441                self.max_tokens = self.max_tokens.saturating_add(256).min(128_000);
442            }
443            SettingsField::MaxTurns => {
444                self.max_turns = self.max_turns.saturating_add(10);
445            }
446            SettingsField::ObservationMask => {
447                self.observation_mask = (self.observation_mask + 0.05).min(1.0);
448            }
449            SettingsField::ReadMaxLines => {
450                self.read_max_lines = self.read_max_lines.saturating_add(100);
451            }
452            SettingsField::SidebarWidth => {
453                self.sidebar_width = (self.sidebar_width + 5).min(80);
454            }
455            SettingsField::WordWrap => {
456                self.word_wrap = !self.word_wrap;
457            }
458            SettingsField::Animations => {
459                self.animations = match self.animations {
460                    AnimationLevel::None => AnimationLevel::Spinner,
461                    AnimationLevel::Spinner => AnimationLevel::Minimal,
462                    AnimationLevel::Minimal => AnimationLevel::None,
463                };
464            }
465            SettingsField::AutoOpenSidebar => {
466                self.auto_open_sidebar = !self.auto_open_sidebar;
467            }
468            SettingsField::SidebarAutoOpenWidth => {
469                self.sidebar_auto_open_width = (self.sidebar_auto_open_width + 10).min(240);
470            }
471            SettingsField::ThinkingLines => {
472                self.thinking_lines = self.thinking_lines.saturating_add(1).min(20);
473            }
474            SettingsField::StreamingLines => {
475                self.streaming_lines = self.streaming_lines.saturating_add(1).min(20);
476            }
477            SettingsField::MouseScrollLines => {
478                self.mouse_scroll_lines = self.mouse_scroll_lines.saturating_add(1).min(20);
479            }
480            SettingsField::KeyboardScrollLines => {
481                self.keyboard_scroll_lines = self.keyboard_scroll_lines.saturating_add(5).min(100);
482            }
483            SettingsField::ShowTimestamps => {
484                self.show_timestamps = !self.show_timestamps;
485            }
486            SettingsField::ShowCost => {
487                self.show_cost = !self.show_cost;
488            }
489            SettingsField::ShowContextUsage => {
490                self.show_context_usage = !self.show_context_usage;
491            }
492            SettingsField::NotifyOnAgentComplete => {
493                self.notify_on_agent_complete = !self.notify_on_agent_complete;
494            }
495            SettingsField::ContinuePolicy => {
496                self.continue_policy = match self.continue_policy {
497                    ContinuePolicy::Disabled => ContinuePolicy::Conservative,
498                    ContinuePolicy::Conservative => ContinuePolicy::Balanced,
499                    ContinuePolicy::Balanced => ContinuePolicy::Aggressive,
500                    ContinuePolicy::Aggressive => ContinuePolicy::Disabled,
501                };
502            }
503            SettingsField::ImproveAutoTurnBudget => {
504                self.improve_auto_turn_budget =
505                    self.improve_auto_turn_budget.saturating_add(1).min(100);
506            }
507            SettingsField::LoopTurnBudget => {
508                self.loop_turn_budget = if self.loop_turn_budget >= 100 {
509                    0
510                } else {
511                    self.loop_turn_budget.saturating_add(1)
512                };
513            }
514            SettingsField::WebSearchProvider => {
515                self.web_search_provider = match self.web_search_provider {
516                    None => Some(SearchProvider::Tavily),
517                    Some(SearchProvider::Tavily) => Some(SearchProvider::Exa),
518                    Some(SearchProvider::Exa) => Some(SearchProvider::Linkup),
519                    Some(SearchProvider::Linkup) => Some(SearchProvider::Perplexity),
520                    Some(SearchProvider::Perplexity) | Some(SearchProvider::GitHub) => None,
521                };
522            }
523            SettingsField::ManaScope => {
524                self.mana_scope = match self.mana_scope {
525                    ManaScopePreference::Project => ManaScopePreference::Root,
526                    ManaScopePreference::Root => ManaScopePreference::Project,
527                };
528            }
529            SettingsField::ManaAutoCommit => {
530                self.mana_auto_commit = !self.mana_auto_commit;
531            }
532            SettingsField::ManaAutoCloseParent => {
533                self.mana_auto_close_parent = !self.mana_auto_close_parent;
534            }
535            SettingsField::ManaVerifyTimeout => {
536                self.mana_verify_timeout = self.mana_verify_timeout.saturating_add(30).min(3600);
537            }
538            SettingsField::ManaRunBackground => {
539                self.mana_run_background = !self.mana_run_background;
540            }
541            SettingsField::ManaMaxWorkers => {
542                self.mana_max_workers = self.mana_max_workers.saturating_add(1).min(32);
543            }
544            SettingsField::ManaReviewAfterRun => {
545                self.mana_review_after_run = !self.mana_review_after_run;
546            }
547            SettingsField::ManaContinueAfterFailure => {
548                self.mana_continue_after_failure = !self.mana_continue_after_failure;
549            }
550            SettingsField::TavilyApiKey => {}
551            SettingsField::ExaApiKey => {}
552            SettingsField::Save => {}
553        }
554    }
555
556    /// Cycle the current field's value backward.
557    pub fn cycle_backward(&mut self) {
558        self.dirty = true;
559        match self.current_field() {
560            SettingsField::Model => {
561                if !self.model_options.is_empty() {
562                    if let Some(idx) = self.model_options.iter().position(|m| *m == self.model) {
563                        let prev = if idx == 0 {
564                            self.model_options.len() - 1
565                        } else {
566                            idx - 1
567                        };
568                        self.model = self.model_options[prev].clone();
569                    }
570                }
571            }
572            SettingsField::ChosenModels => {
573                self.toggle_current_model_in_chosen();
574            }
575            SettingsField::Theme => {
576                if !self.theme_options.is_empty() {
577                    let idx = self
578                        .theme_options
579                        .iter()
580                        .position(|t| *t == self.theme_name)
581                        .unwrap_or(0);
582                    let prev = if idx == 0 {
583                        self.theme_options.len() - 1
584                    } else {
585                        idx - 1
586                    };
587                    self.theme_name = self.theme_options[prev].clone();
588                }
589            }
590            SettingsField::ThinkingLevel => {
591                self.thinking_level = prev_thinking(self.thinking_level);
592            }
593            SettingsField::MaxTokens => {
594                self.max_tokens = self.max_tokens.saturating_sub(256).max(1);
595            }
596            SettingsField::MaxTurns => {
597                self.max_turns = self.max_turns.saturating_sub(10).max(1);
598            }
599            SettingsField::ObservationMask => {
600                self.observation_mask = (self.observation_mask - 0.05).max(0.0);
601            }
602            SettingsField::ReadMaxLines => {
603                self.read_max_lines = self.read_max_lines.saturating_sub(100);
604            }
605            SettingsField::SidebarWidth => {
606                self.sidebar_width = self.sidebar_width.saturating_sub(5).max(20);
607            }
608            SettingsField::WordWrap => {
609                self.word_wrap = !self.word_wrap;
610            }
611            SettingsField::Animations => {
612                self.animations = match self.animations {
613                    AnimationLevel::None => AnimationLevel::Minimal,
614                    AnimationLevel::Spinner => AnimationLevel::None,
615                    AnimationLevel::Minimal => AnimationLevel::Spinner,
616                };
617            }
618            SettingsField::AutoOpenSidebar => {
619                self.auto_open_sidebar = !self.auto_open_sidebar;
620            }
621            SettingsField::SidebarAutoOpenWidth => {
622                self.sidebar_auto_open_width =
623                    self.sidebar_auto_open_width.saturating_sub(10).max(40);
624            }
625            SettingsField::ThinkingLines => {
626                self.thinking_lines = self.thinking_lines.saturating_sub(1).max(1);
627            }
628            SettingsField::StreamingLines => {
629                self.streaming_lines = self.streaming_lines.saturating_sub(1).max(1);
630            }
631            SettingsField::MouseScrollLines => {
632                self.mouse_scroll_lines = self.mouse_scroll_lines.saturating_sub(1).max(1);
633            }
634            SettingsField::KeyboardScrollLines => {
635                self.keyboard_scroll_lines = self.keyboard_scroll_lines.saturating_sub(5).max(5);
636            }
637            SettingsField::ShowTimestamps => {
638                self.show_timestamps = !self.show_timestamps;
639            }
640            SettingsField::ShowCost => {
641                self.show_cost = !self.show_cost;
642            }
643            SettingsField::ShowContextUsage => {
644                self.show_context_usage = !self.show_context_usage;
645            }
646            SettingsField::NotifyOnAgentComplete => {
647                self.notify_on_agent_complete = !self.notify_on_agent_complete;
648            }
649            SettingsField::ContinuePolicy => {
650                self.continue_policy = match self.continue_policy {
651                    ContinuePolicy::Disabled => ContinuePolicy::Aggressive,
652                    ContinuePolicy::Conservative => ContinuePolicy::Disabled,
653                    ContinuePolicy::Balanced => ContinuePolicy::Conservative,
654                    ContinuePolicy::Aggressive => ContinuePolicy::Balanced,
655                };
656            }
657            SettingsField::ImproveAutoTurnBudget => {
658                self.improve_auto_turn_budget =
659                    self.improve_auto_turn_budget.saturating_sub(1).max(1);
660            }
661            SettingsField::LoopTurnBudget => {
662                self.loop_turn_budget = self.loop_turn_budget.saturating_sub(1);
663            }
664            SettingsField::WebSearchProvider => {
665                self.web_search_provider = match self.web_search_provider {
666                    None => Some(SearchProvider::Perplexity),
667                    Some(SearchProvider::Tavily) | Some(SearchProvider::GitHub) => None,
668                    Some(SearchProvider::Exa) => Some(SearchProvider::Tavily),
669                    Some(SearchProvider::Linkup) => Some(SearchProvider::Exa),
670                    Some(SearchProvider::Perplexity) => Some(SearchProvider::Linkup),
671                };
672            }
673            SettingsField::ManaScope => {
674                self.mana_scope = match self.mana_scope {
675                    ManaScopePreference::Project => ManaScopePreference::Root,
676                    ManaScopePreference::Root => ManaScopePreference::Project,
677                };
678            }
679            SettingsField::ManaAutoCommit => {
680                self.mana_auto_commit = !self.mana_auto_commit;
681            }
682            SettingsField::ManaAutoCloseParent => {
683                self.mana_auto_close_parent = !self.mana_auto_close_parent;
684            }
685            SettingsField::ManaVerifyTimeout => {
686                self.mana_verify_timeout = self.mana_verify_timeout.saturating_sub(30);
687            }
688            SettingsField::ManaRunBackground => {
689                self.mana_run_background = !self.mana_run_background;
690            }
691            SettingsField::ManaMaxWorkers => {
692                self.mana_max_workers = self.mana_max_workers.saturating_sub(1).max(1);
693            }
694            SettingsField::ManaReviewAfterRun => {
695                self.mana_review_after_run = !self.mana_review_after_run;
696            }
697            SettingsField::ManaContinueAfterFailure => {
698                self.mana_continue_after_failure = !self.mana_continue_after_failure;
699            }
700            SettingsField::TavilyApiKey => {}
701            SettingsField::ExaApiKey => {}
702            SettingsField::Save => {}
703        }
704    }
705
706    /// Begin direct numeric input for the current field.
707    pub fn start_edit(&mut self) {
708        match self.current_field() {
709            SettingsField::MaxTokens => {
710                self.editing_number = true;
711                self.edit_buffer = self.max_tokens.to_string();
712            }
713            SettingsField::MaxTurns => {
714                self.editing_number = true;
715                self.edit_buffer = self.max_turns.to_string();
716            }
717            SettingsField::ImproveAutoTurnBudget => {
718                self.editing_number = true;
719                self.edit_buffer = self.improve_auto_turn_budget.to_string();
720            }
721            SettingsField::LoopTurnBudget => {
722                self.editing_number = true;
723                self.edit_buffer = self.loop_turn_budget.to_string();
724            }
725            SettingsField::ObservationMask => {
726                self.editing_number = true;
727                self.edit_buffer = format!("{:.2}", self.observation_mask);
728            }
729            SettingsField::ReadMaxLines => {
730                self.editing_number = true;
731                self.edit_buffer = self.read_max_lines.to_string();
732            }
733            SettingsField::ManaVerifyTimeout => {
734                self.editing_number = true;
735                self.edit_buffer = self.mana_verify_timeout.to_string();
736            }
737            SettingsField::ManaMaxWorkers => {
738                self.editing_number = true;
739                self.edit_buffer = self.mana_max_workers.to_string();
740            }
741            SettingsField::SidebarWidth => {
742                self.editing_number = true;
743                self.edit_buffer = self.sidebar_width.to_string();
744            }
745            SettingsField::TavilyApiKey => {
746                self.editing_number = false;
747                self.edit_buffer = self.tavily_api_key.clone();
748            }
749            SettingsField::ExaApiKey => {
750                self.editing_number = false;
751                self.edit_buffer = self.exa_api_key.clone();
752            }
753            _ => {
754                // For enum/bool fields, Enter cycles forward
755                self.cycle_forward();
756            }
757        }
758    }
759
760    pub fn push_char(&mut self, c: char) {
761        if self.editing_number {
762            if c.is_ascii_digit() || c == '.' {
763                self.edit_buffer.push(c);
764            }
765            return;
766        }
767
768        match self.current_field() {
769            SettingsField::TavilyApiKey => {
770                self.tavily_api_key.push(c);
771                self.dirty = true;
772            }
773            SettingsField::ExaApiKey => {
774                self.exa_api_key.push(c);
775                self.dirty = true;
776            }
777            SettingsField::ChosenModels => {
778                if !c.is_control() {
779                    let lower = c.to_ascii_lowercase();
780                    if let Some(next) = self
781                        .model_options
782                        .iter()
783                        .find(|m| m.to_ascii_lowercase().starts_with(lower))
784                    {
785                        self.model = next.clone();
786                    }
787                }
788            }
789            _ => {}
790        }
791    }
792
793    pub fn pop_char(&mut self) {
794        if self.editing_number {
795            self.edit_buffer.pop();
796            return;
797        }
798
799        match self.current_field() {
800            SettingsField::TavilyApiKey => {
801                self.tavily_api_key.pop();
802                self.dirty = true;
803            }
804            SettingsField::ExaApiKey => {
805                self.exa_api_key.pop();
806                self.dirty = true;
807            }
808            _ => {}
809        }
810    }
811
812    /// Commit the edit buffer to the underlying field value.
813    pub fn commit_edit(&mut self) {
814        if !self.editing_number {
815            return;
816        }
817        self.editing_number = false;
818        self.dirty = true;
819        match self.current_field() {
820            SettingsField::MaxTokens => {
821                if let Ok(v) = self.edit_buffer.parse::<u32>() {
822                    self.max_tokens = v.max(1);
823                }
824            }
825            SettingsField::MaxTurns => {
826                if let Ok(v) = self.edit_buffer.parse::<u32>() {
827                    self.max_turns = v.max(1);
828                }
829            }
830            SettingsField::ImproveAutoTurnBudget => {
831                if let Ok(v) = self.edit_buffer.parse::<u32>() {
832                    self.improve_auto_turn_budget = v.clamp(1, 100);
833                }
834            }
835            SettingsField::LoopTurnBudget => {
836                if let Ok(v) = self.edit_buffer.parse::<u32>() {
837                    self.loop_turn_budget = v.min(100);
838                }
839            }
840            SettingsField::ObservationMask => {
841                if let Ok(v) = self.edit_buffer.parse::<f64>() {
842                    self.observation_mask = v.clamp(0.0, 1.0);
843                }
844            }
845            SettingsField::ReadMaxLines => {
846                if let Ok(v) = self.edit_buffer.parse::<usize>() {
847                    self.read_max_lines = v;
848                }
849            }
850            SettingsField::ManaVerifyTimeout => {
851                if let Ok(v) = self.edit_buffer.parse::<u64>() {
852                    self.mana_verify_timeout = v.min(3600);
853                }
854            }
855            SettingsField::ManaMaxWorkers => {
856                if let Ok(v) = self.edit_buffer.parse::<u32>() {
857                    self.mana_max_workers = v.clamp(1, 32);
858                }
859            }
860            SettingsField::SidebarWidth => {
861                if let Ok(v) = self.edit_buffer.parse::<u16>() {
862                    self.sidebar_width = v.clamp(20, 80);
863                }
864            }
865            _ => {}
866        }
867        self.edit_buffer.clear();
868    }
869
870    /// Write current settings into a Config for saving and in-session use.
871    pub fn apply_to_config(&self, config: &mut Config) {
872        config.model = Some(self.model.clone());
873        config.enabled_models = if self.chosen_models.is_empty() {
874            None
875        } else {
876            Some(self.chosen_models.clone())
877        };
878        config.theme = Some(self.theme_name.clone());
879        config.thinking = Some(self.thinking_level);
880        config.max_tokens = Some(self.max_tokens);
881        config.max_turns = Some(self.max_turns);
882        config.context = ContextConfig {
883            observation_mask_threshold: self.observation_mask,
884            ..config.context.clone()
885        };
886        config.ui = imp_core::config::UiConfig {
887            sidebar_style: SidebarStyle::Inspector,
888            tool_output: ToolOutputDisplay::Full,
889            tool_output_lines: self.tool_output_lines,
890            read_max_lines: self.read_max_lines,
891            sidebar_width: self.sidebar_width,
892            word_wrap: self.word_wrap,
893            animations: self.animations,
894            hide_tools_in_chat: false,
895            chat_tool_display: ChatToolDisplay::Summary,
896            auto_open_sidebar: self.auto_open_sidebar,
897            sidebar_auto_open_width: self.sidebar_auto_open_width,
898            thinking_lines: self.thinking_lines,
899            streaming_lines: self.streaming_lines,
900            mouse_scroll_lines: self.mouse_scroll_lines,
901            keyboard_scroll_lines: self.keyboard_scroll_lines,
902            mouse_capture: config.ui.mouse_capture,
903            show_timestamps: self.show_timestamps,
904            show_cost: self.show_cost,
905            show_context_usage: self.show_context_usage,
906            notify_on_agent_complete: self.notify_on_agent_complete,
907            continue_policy: self.continue_policy,
908            build_auto_turn_budget: config.ui.build_auto_turn_budget,
909            improve_auto_turn_budget: self.improve_auto_turn_budget,
910            loop_turn_budget: self.loop_turn_budget,
911        };
912        config.web = imp_core::tools::web::types::WebConfig {
913            search_provider: self.web_search_provider,
914        };
915        config.mana = ManaConfig {
916            scope: self.mana_scope,
917            auto_commit: self.mana_auto_commit,
918            auto_close_parent: self.mana_auto_close_parent,
919            verify_timeout: (self.mana_verify_timeout > 0).then_some(self.mana_verify_timeout),
920            run: ManaRunConfig {
921                background: self.mana_run_background,
922                max_workers: self.mana_max_workers.max(1),
923                continue_after_failure: self.mana_continue_after_failure,
924                review_after_run: self.mana_review_after_run,
925            },
926        };
927    }
928    fn model_is_chosen(&self, model_id: &str) -> bool {
929        self.chosen_models.iter().any(|m| m == model_id)
930    }
931
932    fn toggle_current_model_in_chosen(&mut self) {
933        let model = self.model.clone();
934        if let Some(idx) = self.chosen_models.iter().position(|m| m == &model) {
935            self.chosen_models.remove(idx);
936        } else {
937            self.chosen_models.push(model);
938        }
939    }
940
941    fn chosen_models_summary(&self) -> String {
942        if self.chosen_models.is_empty() {
943            "all models".to_string()
944        } else {
945            format!("{} chosen", self.chosen_models.len())
946        }
947    }
948}
949
950fn theme_options(current: Option<&str>) -> Vec<String> {
951    let mut options = vec!["default".to_string(), "light".to_string()];
952    if let Some(current) = current.filter(|value| !value.trim().is_empty()) {
953        if !options.iter().any(|option| option == current) {
954            options.push(current.to_string());
955        }
956    }
957    options
958}
959
960fn next_thinking(level: ThinkingLevel) -> ThinkingLevel {
961    match level {
962        ThinkingLevel::Off => ThinkingLevel::Low,
963        ThinkingLevel::Minimal => ThinkingLevel::Low,
964        ThinkingLevel::Low => ThinkingLevel::Medium,
965        ThinkingLevel::Medium => ThinkingLevel::High,
966        ThinkingLevel::High => ThinkingLevel::XHigh,
967        ThinkingLevel::XHigh => ThinkingLevel::Off,
968    }
969}
970
971fn prev_thinking(level: ThinkingLevel) -> ThinkingLevel {
972    match level {
973        ThinkingLevel::Off => ThinkingLevel::XHigh,
974        ThinkingLevel::Minimal => ThinkingLevel::Off,
975        ThinkingLevel::Low => ThinkingLevel::Off,
976        ThinkingLevel::Medium => ThinkingLevel::Low,
977        ThinkingLevel::High => ThinkingLevel::Medium,
978        ThinkingLevel::XHigh => ThinkingLevel::High,
979    }
980}
981
982fn thinking_label(level: ThinkingLevel) -> &'static str {
983    match level {
984        ThinkingLevel::Off => "Off",
985        ThinkingLevel::Minimal => "Minimal",
986        ThinkingLevel::Low => "Low",
987        ThinkingLevel::Medium => "Medium",
988        ThinkingLevel::High => "High",
989        ThinkingLevel::XHigh => "XHigh",
990    }
991}
992
993fn animation_label(level: AnimationLevel) -> &'static str {
994    match level {
995        AnimationLevel::None => "none",
996        AnimationLevel::Spinner => "spinner",
997        AnimationLevel::Minimal => "minimal",
998    }
999}
1000
1001enum SettingsRow {
1002    Header,
1003    Tabs,
1004    Field(SettingsField),
1005    EmptyMessage,
1006    Save,
1007}
1008
1009fn visit_settings_rows(state: &SettingsState, mut visit: impl FnMut(SettingsRow, u16)) {
1010    let mut row: u16 = 0;
1011    visit(SettingsRow::Header, row);
1012    row += 2;
1013    visit(SettingsRow::Tabs, row);
1014    row += 2;
1015
1016    let fields = state.visible_fields();
1017    if fields.is_empty() {
1018        visit(SettingsRow::EmptyMessage, row);
1019        row += 1;
1020    } else {
1021        for field in fields {
1022            visit(SettingsRow::Field(*field), row);
1023            row += 1;
1024        }
1025    }
1026
1027    row += 1;
1028    visit(SettingsRow::Save, row);
1029}
1030
1031fn total_settings_rows(state: &SettingsState) -> u16 {
1032    let mut total = 0;
1033    visit_settings_rows(state, |_, row| {
1034        total = row.saturating_add(1);
1035    });
1036    total
1037}
1038
1039fn selected_settings_row(state: &SettingsState) -> u16 {
1040    let selected = state.current_field();
1041    let mut selected_row = 0;
1042    visit_settings_rows(state, |entry, row| match entry {
1043        SettingsRow::Field(field) if field == selected => selected_row = row,
1044        SettingsRow::Save if selected == SettingsField::Save => selected_row = row,
1045        _ => {}
1046    });
1047    selected_row
1048}
1049
1050fn settings_scroll_offset(state: &SettingsState, visible_rows: u16) -> u16 {
1051    if visible_rows == 0 {
1052        return 0;
1053    }
1054
1055    let total_rows = total_settings_rows(state);
1056    if total_rows <= visible_rows {
1057        return 0;
1058    }
1059
1060    let selected_row = selected_settings_row(state);
1061    let desired = selected_row.saturating_sub(visible_rows.saturating_sub(1));
1062    desired.min(total_rows.saturating_sub(visible_rows))
1063}
1064
1065fn scrolled_screen_y(inner: Rect, logical_row: u16, scroll_offset: u16) -> Option<u16> {
1066    if logical_row < scroll_offset {
1067        return None;
1068    }
1069
1070    let visible_row = logical_row - scroll_offset;
1071    if visible_row >= inner.height {
1072        return None;
1073    }
1074
1075    Some(inner.y + visible_row)
1076}
1077
1078/// Settings overlay widget.
1079pub struct SettingsView<'a> {
1080    state: &'a SettingsState,
1081    theme: &'a Theme,
1082}
1083
1084impl<'a> SettingsView<'a> {
1085    pub fn new(state: &'a SettingsState, theme: &'a Theme) -> Self {
1086        Self { state, theme }
1087    }
1088}
1089
1090impl Widget for SettingsView<'_> {
1091    fn render(self, area: Rect, buf: &mut Buffer) {
1092        if area.height < 10 || area.width < 30 {
1093            return;
1094        }
1095
1096        Clear.render(area, buf);
1097
1098        let title = if self.state.dirty {
1099            " Settings * "
1100        } else {
1101            " Settings "
1102        };
1103        let block = Block::default()
1104            .title(title)
1105            .borders(Borders::ALL)
1106            .border_style(self.theme.accent_style());
1107        let inner = block.inner(area);
1108        block.render(area, buf);
1109
1110        let total_rows = total_settings_rows(self.state);
1111        let scroll_offset = settings_scroll_offset(self.state, inner.height);
1112
1113        let mut row: u16 = 0;
1114
1115        render_settings_header(self.state, self.theme, buf, inner, scroll_offset, &mut row);
1116        render_settings_tabs(self.state, self.theme, buf, inner, scroll_offset, &mut row);
1117
1118        if self.state.visible_fields().is_empty() {
1119            if let Some(message) = self.state.tab.empty_message() {
1120                if let Some(y) = scrolled_screen_y(inner, row, scroll_offset) {
1121                    let line = Line::from(vec![
1122                        Span::raw("  "),
1123                        Span::styled(message, self.theme.muted_style()),
1124                    ]);
1125                    buf.set_line(inner.x, y, &line, inner.width);
1126                }
1127                row += 1;
1128            }
1129        } else {
1130            for field in self.state.visible_fields() {
1131                render_settings_field(
1132                    self.state,
1133                    self.theme,
1134                    buf,
1135                    inner,
1136                    scroll_offset,
1137                    &mut row,
1138                    *field,
1139                );
1140            }
1141        }
1142
1143        row += 1;
1144        render_save_row(self.state, self.theme, buf, inner, scroll_offset, row);
1145
1146        if scroll_offset > 0 {
1147            let hint = Line::from(Span::styled("↑ more", self.theme.muted_style()));
1148            buf.set_line(inner.x + inner.width.saturating_sub(7), inner.y, &hint, 7);
1149        }
1150        if scroll_offset + inner.height < total_rows {
1151            let hint = Line::from(Span::styled("↓ more", self.theme.muted_style()));
1152            let y = inner.y + inner.height.saturating_sub(1);
1153            buf.set_line(inner.x + inner.width.saturating_sub(7), y, &hint, 7);
1154        }
1155    }
1156}
1157
1158fn render_settings_header(
1159    state: &SettingsState,
1160    theme: &Theme,
1161    buf: &mut Buffer,
1162    inner: Rect,
1163    scroll_offset: u16,
1164    row: &mut u16,
1165) {
1166    let header = Line::from(Span::styled(
1167        "  Tab switch  ↑/↓ move  ←/→ change  Enter edit  Esc close",
1168        theme.muted_style(),
1169    ));
1170    if let Some(y) = scrolled_screen_y(inner, *row, scroll_offset) {
1171        buf.set_line(inner.x, y, &header, inner.width);
1172    }
1173    *row += 2;
1174
1175    let _ = state;
1176}
1177
1178fn render_settings_tabs(
1179    state: &SettingsState,
1180    theme: &Theme,
1181    buf: &mut Buffer,
1182    inner: Rect,
1183    scroll_offset: u16,
1184    row: &mut u16,
1185) {
1186    let mut spans = vec![Span::raw("  ")];
1187    for (idx, tab) in SETTINGS_TABS.iter().enumerate() {
1188        if idx > 0 {
1189            spans.push(Span::styled("  ", theme.muted_style()));
1190        }
1191        let label = format!(" {} ", tab.label());
1192        if *tab == state.tab {
1193            spans.push(Span::styled(
1194                label,
1195                Style::default()
1196                    .fg(theme.accent)
1197                    .add_modifier(Modifier::BOLD | Modifier::REVERSED),
1198            ));
1199        } else {
1200            spans.push(Span::styled(label, theme.muted_style()));
1201        }
1202    }
1203
1204    if let Some(y) = scrolled_screen_y(inner, *row, scroll_offset) {
1205        buf.set_line(inner.x, y, &Line::from(spans), inner.width);
1206    }
1207    *row += 2;
1208}
1209
1210fn render_settings_field(
1211    state: &SettingsState,
1212    theme: &Theme,
1213    buf: &mut Buffer,
1214    inner: Rect,
1215    scroll_offset: u16,
1216    row: &mut u16,
1217    field: SettingsField,
1218) {
1219    match field {
1220        SettingsField::Model => render_field(
1221            state,
1222            theme,
1223            buf,
1224            inner,
1225            scroll_offset,
1226            row,
1227            field_index(SettingsField::Model),
1228            "Model",
1229            &state.model,
1230            "← →",
1231        ),
1232        SettingsField::ChosenModels => {
1233            let chosen_hint = if state.model_is_chosen(&state.model) {
1234                "← → toggle current"
1235            } else {
1236                "← → add current"
1237            };
1238            let chosen_summary = state.chosen_models_summary();
1239            render_field(
1240                state,
1241                theme,
1242                buf,
1243                inner,
1244                scroll_offset,
1245                row,
1246                field_index(SettingsField::ChosenModels),
1247                "Chosen models",
1248                &chosen_summary,
1249                chosen_hint,
1250            );
1251        }
1252        SettingsField::Theme => render_field(
1253            state,
1254            theme,
1255            buf,
1256            inner,
1257            scroll_offset,
1258            row,
1259            field_index(SettingsField::Theme),
1260            "Color theme",
1261            &state.theme_name,
1262            "← → (UI colors)",
1263        ),
1264        SettingsField::ThinkingLevel => render_field(
1265            state,
1266            theme,
1267            buf,
1268            inner,
1269            scroll_offset,
1270            row,
1271            field_index(SettingsField::ThinkingLevel),
1272            "Thinking level",
1273            thinking_label(state.thinking_level),
1274            "← →",
1275        ),
1276        SettingsField::MaxTokens => {
1277            let value = if state.editing_number && state.current_field() == SettingsField::MaxTokens
1278            {
1279                format!("{}▎", state.edit_buffer)
1280            } else {
1281                state.max_tokens.to_string()
1282            };
1283            render_field(
1284                state,
1285                theme,
1286                buf,
1287                inner,
1288                scroll_offset,
1289                row,
1290                field_index(field),
1291                "Max tokens",
1292                &value,
1293                "← → / type",
1294            );
1295        }
1296        SettingsField::MaxTurns => {
1297            let value = if state.editing_number && state.current_field() == SettingsField::MaxTurns
1298            {
1299                format!("{}▎", state.edit_buffer)
1300            } else {
1301                state.max_turns.to_string()
1302            };
1303            render_field(
1304                state,
1305                theme,
1306                buf,
1307                inner,
1308                scroll_offset,
1309                row,
1310                field_index(field),
1311                "Max turns",
1312                &value,
1313                "← → / type",
1314            );
1315        }
1316        SettingsField::ObservationMask => {
1317            let value = if state.editing_number
1318                && state.current_field() == SettingsField::ObservationMask
1319            {
1320                format!("{}▎", state.edit_buffer)
1321            } else {
1322                format!("{:.0}%", state.observation_mask * 100.0)
1323            };
1324            render_field(
1325                state,
1326                theme,
1327                buf,
1328                inner,
1329                scroll_offset,
1330                row,
1331                field_index(field),
1332                "Observation mask",
1333                &value,
1334                "← →",
1335            );
1336        }
1337        SettingsField::ReadMaxLines => {
1338            let value =
1339                if state.editing_number && state.current_field() == SettingsField::ReadMaxLines {
1340                    format!("{}▎", state.edit_buffer)
1341                } else {
1342                    state.read_max_lines.to_string()
1343                };
1344            render_field(
1345                state,
1346                theme,
1347                buf,
1348                inner,
1349                scroll_offset,
1350                row,
1351                field_index(field),
1352                "Read max lines",
1353                &value,
1354                "← → / type (0 = no limit)",
1355            );
1356        }
1357        SettingsField::SidebarWidth => {
1358            let value =
1359                if state.editing_number && state.current_field() == SettingsField::SidebarWidth {
1360                    format!("{}▎", state.edit_buffer)
1361                } else {
1362                    format!("{}%", state.sidebar_width)
1363                };
1364            render_field(
1365                state,
1366                theme,
1367                buf,
1368                inner,
1369                scroll_offset,
1370                row,
1371                field_index(field),
1372                "Inspector width",
1373                &value,
1374                "← → / type",
1375            );
1376        }
1377        SettingsField::WordWrap => render_field(
1378            state,
1379            theme,
1380            buf,
1381            inner,
1382            scroll_offset,
1383            row,
1384            field_index(field),
1385            "Word wrap",
1386            if state.word_wrap { "on" } else { "off" },
1387            "← →",
1388        ),
1389        SettingsField::Animations => render_field(
1390            state,
1391            theme,
1392            buf,
1393            inner,
1394            scroll_offset,
1395            row,
1396            field_index(field),
1397            "Animations",
1398            animation_label(state.animations),
1399            "← →",
1400        ),
1401        SettingsField::AutoOpenSidebar => render_field(
1402            state,
1403            theme,
1404            buf,
1405            inner,
1406            scroll_offset,
1407            row,
1408            field_index(field),
1409            "Auto-open sidebar",
1410            if state.auto_open_sidebar { "on" } else { "off" },
1411            "← →",
1412        ),
1413        SettingsField::SidebarAutoOpenWidth => {
1414            let value = if state.editing_number
1415                && state.current_field() == SettingsField::SidebarAutoOpenWidth
1416            {
1417                format!("{}▎", state.edit_buffer)
1418            } else {
1419                state.sidebar_auto_open_width.to_string()
1420            };
1421            render_field(
1422                state,
1423                theme,
1424                buf,
1425                inner,
1426                scroll_offset,
1427                row,
1428                field_index(field),
1429                "Auto-open width",
1430                &value,
1431                "← → / type",
1432            );
1433        }
1434        SettingsField::ThinkingLines => {
1435            let value =
1436                if state.editing_number && state.current_field() == SettingsField::ThinkingLines {
1437                    format!("{}▎", state.edit_buffer)
1438                } else {
1439                    state.thinking_lines.to_string()
1440                };
1441            render_field(
1442                state,
1443                theme,
1444                buf,
1445                inner,
1446                scroll_offset,
1447                row,
1448                field_index(field),
1449                "Thinking lines",
1450                &value,
1451                "← → / type",
1452            );
1453        }
1454        SettingsField::StreamingLines => {
1455            let value =
1456                if state.editing_number && state.current_field() == SettingsField::StreamingLines {
1457                    format!("{}▎", state.edit_buffer)
1458                } else {
1459                    state.streaming_lines.to_string()
1460                };
1461            render_field(
1462                state,
1463                theme,
1464                buf,
1465                inner,
1466                scroll_offset,
1467                row,
1468                field_index(field),
1469                "Streaming lines",
1470                &value,
1471                "← → / type",
1472            );
1473        }
1474        SettingsField::MouseScrollLines => {
1475            let value = if state.editing_number
1476                && state.current_field() == SettingsField::MouseScrollLines
1477            {
1478                format!("{}▎", state.edit_buffer)
1479            } else {
1480                state.mouse_scroll_lines.to_string()
1481            };
1482            render_field(
1483                state,
1484                theme,
1485                buf,
1486                inner,
1487                scroll_offset,
1488                row,
1489                field_index(field),
1490                "Mouse scroll",
1491                &value,
1492                "← → / type",
1493            );
1494        }
1495        SettingsField::KeyboardScrollLines => {
1496            let value = if state.editing_number
1497                && state.current_field() == SettingsField::KeyboardScrollLines
1498            {
1499                format!("{}▎", state.edit_buffer)
1500            } else {
1501                state.keyboard_scroll_lines.to_string()
1502            };
1503            render_field(
1504                state,
1505                theme,
1506                buf,
1507                inner,
1508                scroll_offset,
1509                row,
1510                field_index(field),
1511                "Keyboard scroll",
1512                &value,
1513                "← → / type",
1514            );
1515        }
1516        SettingsField::ShowTimestamps => render_field(
1517            state,
1518            theme,
1519            buf,
1520            inner,
1521            scroll_offset,
1522            row,
1523            field_index(field),
1524            "Show timestamps",
1525            if state.show_timestamps { "on" } else { "off" },
1526            "← →",
1527        ),
1528        SettingsField::ShowCost => render_field(
1529            state,
1530            theme,
1531            buf,
1532            inner,
1533            scroll_offset,
1534            row,
1535            field_index(field),
1536            "Show cost",
1537            if state.show_cost { "on" } else { "off" },
1538            "← →",
1539        ),
1540        SettingsField::ShowContextUsage => render_field(
1541            state,
1542            theme,
1543            buf,
1544            inner,
1545            scroll_offset,
1546            row,
1547            field_index(field),
1548            "Show context",
1549            if state.show_context_usage {
1550                "on"
1551            } else {
1552                "off"
1553            },
1554            "← →",
1555        ),
1556        SettingsField::NotifyOnAgentComplete => render_field(
1557            state,
1558            theme,
1559            buf,
1560            inner,
1561            scroll_offset,
1562            row,
1563            field_index(field),
1564            "Bell on done",
1565            if state.notify_on_agent_complete {
1566                "on"
1567            } else {
1568                "off"
1569            },
1570            "← →",
1571        ),
1572        SettingsField::ContinuePolicy => render_field(
1573            state,
1574            theme,
1575            buf,
1576            inner,
1577            scroll_offset,
1578            row,
1579            field_index(field),
1580            "Looping",
1581            match state.continue_policy {
1582                ContinuePolicy::Disabled => "off",
1583                ContinuePolicy::Conservative => "conservative",
1584                ContinuePolicy::Balanced => "balanced",
1585                ContinuePolicy::Aggressive => "aggressive",
1586            },
1587            "← →",
1588        ),
1589        SettingsField::ImproveAutoTurnBudget => {
1590            let value = if state.editing_number
1591                && state.current_field() == SettingsField::ImproveAutoTurnBudget
1592            {
1593                format!("{}▎", state.edit_buffer)
1594            } else {
1595                state.improve_auto_turn_budget.to_string()
1596            };
1597            render_field(
1598                state,
1599                theme,
1600                buf,
1601                inner,
1602                scroll_offset,
1603                row,
1604                field_index(field),
1605                "Improve turns",
1606                &value,
1607                "← → / type",
1608            );
1609        }
1610        SettingsField::LoopTurnBudget => {
1611            let value =
1612                if state.editing_number && state.current_field() == SettingsField::LoopTurnBudget {
1613                    format!("{}▎", state.edit_buffer)
1614                } else if state.loop_turn_budget == 0 {
1615                    "unlimited".to_string()
1616                } else {
1617                    state.loop_turn_budget.to_string()
1618                };
1619            render_field(
1620                state,
1621                theme,
1622                buf,
1623                inner,
1624                scroll_offset,
1625                row,
1626                field_index(field),
1627                "Loop turns",
1628                &value,
1629                "← → / type",
1630            );
1631        }
1632        SettingsField::WebSearchProvider => render_field(
1633            state,
1634            theme,
1635            buf,
1636            inner,
1637            scroll_offset,
1638            row,
1639            field_index(field),
1640            "Web provider",
1641            match state.web_search_provider {
1642                None => "auto",
1643                Some(SearchProvider::Tavily) => "tavily",
1644                Some(SearchProvider::Exa) => "exa",
1645                Some(SearchProvider::Linkup) => "linkup",
1646                Some(SearchProvider::Perplexity) => "perplexity",
1647                Some(SearchProvider::GitHub) => "github",
1648            },
1649            "← →",
1650        ),
1651        SettingsField::ManaScope => render_field(
1652            state,
1653            theme,
1654            buf,
1655            inner,
1656            scroll_offset,
1657            row,
1658            field_index(field),
1659            "Default scope",
1660            match state.mana_scope {
1661                ManaScopePreference::Project => "project",
1662                ManaScopePreference::Root => "root",
1663            },
1664            "← →",
1665        ),
1666        SettingsField::ManaAutoCommit => render_field(
1667            state,
1668            theme,
1669            buf,
1670            inner,
1671            scroll_offset,
1672            row,
1673            field_index(field),
1674            "Commit on close",
1675            if state.mana_auto_commit { "on" } else { "off" },
1676            "← →",
1677        ),
1678        SettingsField::ManaAutoCloseParent => render_field(
1679            state,
1680            theme,
1681            buf,
1682            inner,
1683            scroll_offset,
1684            row,
1685            field_index(field),
1686            "Auto-close parent",
1687            if state.mana_auto_close_parent {
1688                "on"
1689            } else {
1690                "off"
1691            },
1692            "← →",
1693        ),
1694        SettingsField::ManaVerifyTimeout => {
1695            let value = if state.editing_number
1696                && state.current_field() == SettingsField::ManaVerifyTimeout
1697            {
1698                format!("{}▎", state.edit_buffer)
1699            } else if state.mana_verify_timeout == 0 {
1700                "default".to_string()
1701            } else {
1702                format!("{}s", state.mana_verify_timeout)
1703            };
1704            render_field(
1705                state,
1706                theme,
1707                buf,
1708                inner,
1709                scroll_offset,
1710                row,
1711                field_index(field),
1712                "Verify timeout",
1713                &value,
1714                "← → / type (0 = default)",
1715            );
1716        }
1717        SettingsField::ManaRunBackground => render_field(
1718            state,
1719            theme,
1720            buf,
1721            inner,
1722            scroll_offset,
1723            row,
1724            field_index(field),
1725            "Run in background",
1726            if state.mana_run_background {
1727                "on"
1728            } else {
1729                "off"
1730            },
1731            "← →",
1732        ),
1733        SettingsField::ManaMaxWorkers => {
1734            let value =
1735                if state.editing_number && state.current_field() == SettingsField::ManaMaxWorkers {
1736                    format!("{}▎", state.edit_buffer)
1737                } else {
1738                    state.mana_max_workers.to_string()
1739                };
1740            render_field(
1741                state,
1742                theme,
1743                buf,
1744                inner,
1745                scroll_offset,
1746                row,
1747                field_index(field),
1748                "Max workers",
1749                &value,
1750                "← → / type",
1751            );
1752        }
1753        SettingsField::ManaReviewAfterRun => render_field(
1754            state,
1755            theme,
1756            buf,
1757            inner,
1758            scroll_offset,
1759            row,
1760            field_index(field),
1761            "Review after run",
1762            if state.mana_review_after_run {
1763                "on"
1764            } else {
1765                "off"
1766            },
1767            "← →",
1768        ),
1769        SettingsField::ManaContinueAfterFailure => render_field(
1770            state,
1771            theme,
1772            buf,
1773            inner,
1774            scroll_offset,
1775            row,
1776            field_index(field),
1777            "Continue after failure",
1778            if state.mana_continue_after_failure {
1779                "on"
1780            } else {
1781                "off"
1782            },
1783            "← →",
1784        ),
1785        SettingsField::TavilyApiKey => {
1786            let value = if state.tavily_api_key.is_empty() {
1787                if state.tavily_configured {
1788                    "configured (press Enter to replace)".to_string()
1789                } else {
1790                    "not set".to_string()
1791                }
1792            } else {
1793                format!(
1794                    "{}▎",
1795                    "•".repeat(state.tavily_api_key.chars().count().max(1))
1796                )
1797            };
1798            render_field(
1799                state,
1800                theme,
1801                buf,
1802                inner,
1803                scroll_offset,
1804                row,
1805                field_index(field),
1806                "Tavily API key",
1807                &value,
1808                "Enter to edit",
1809            );
1810        }
1811        SettingsField::ExaApiKey => {
1812            let value = if state.exa_api_key.is_empty() {
1813                if state.exa_configured {
1814                    "configured (press Enter to replace)".to_string()
1815                } else {
1816                    "not set".to_string()
1817                }
1818            } else {
1819                format!("{}▎", "•".repeat(state.exa_api_key.chars().count().max(1)))
1820            };
1821            render_field(
1822                state,
1823                theme,
1824                buf,
1825                inner,
1826                scroll_offset,
1827                row,
1828                field_index(field),
1829                "Exa API key",
1830                &value,
1831                "Enter to edit",
1832            );
1833        }
1834        SettingsField::Save => {}
1835    }
1836}
1837
1838fn render_save_row(
1839    state: &SettingsState,
1840    theme: &Theme,
1841    buf: &mut Buffer,
1842    inner: Rect,
1843    scroll_offset: u16,
1844    row: u16,
1845) {
1846    let Some(y) = scrolled_screen_y(inner, row, scroll_offset) else {
1847        return;
1848    };
1849    let is_save = state.current_field() == SettingsField::Save;
1850    let save_style = if is_save {
1851        Style::default()
1852            .fg(theme.accent)
1853            .add_modifier(Modifier::BOLD)
1854    } else {
1855        theme.muted_style()
1856    };
1857    let marker = if is_save { "▸ " } else { "  " };
1858    let dirty_hint = if state.dirty {
1859        " (unsaved changes)"
1860    } else {
1861        ""
1862    };
1863    let line = Line::from(vec![
1864        Span::styled(marker, theme.accent_style()),
1865        Span::styled("[ Save to config.toml ]", save_style),
1866        Span::styled(dirty_hint, theme.warning_style()),
1867    ]);
1868    buf.set_line(inner.x, y, &line, inner.width);
1869}
1870
1871/// Render one settings field row.
1872#[allow(clippy::too_many_arguments)]
1873fn render_field(
1874    state: &SettingsState,
1875    theme: &Theme,
1876    buf: &mut Buffer,
1877    inner: Rect,
1878    scroll_offset: u16,
1879    row: &mut u16,
1880    field_idx: usize,
1881    label: &str,
1882    value: &str,
1883    hint: &str,
1884) {
1885    let logical_row = *row;
1886    let Some(screen_y) = scrolled_screen_y(inner, logical_row, scroll_offset) else {
1887        *row += 1;
1888        return;
1889    };
1890
1891    let is_selected = field_idx == state.normalized_selected();
1892    let marker = if is_selected { "▸ " } else { "  " };
1893
1894    let label_style = if is_selected {
1895        theme.selected_style()
1896    } else {
1897        Style::default()
1898    };
1899    let value_style = if is_selected {
1900        Style::default()
1901            .fg(theme.accent)
1902            .add_modifier(Modifier::BOLD)
1903    } else {
1904        Style::default()
1905    };
1906
1907    let label_width = 22;
1908    let line = Line::from(vec![
1909        Span::styled(marker, theme.accent_style()),
1910        Span::styled(format!("{label:<label_width$}"), label_style),
1911        Span::styled(value, value_style),
1912        Span::raw("  "),
1913        Span::styled(hint, theme.muted_style()),
1914    ]);
1915    buf.set_line(inner.x, screen_y, &line, inner.width);
1916    *row += 1;
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922    use imp_core::config::Config;
1923    use imp_llm::auth::AuthStore;
1924    use imp_llm::model::ModelRegistry;
1925
1926    #[test]
1927    fn applying_settings_forces_primary_inspector_display_model() {
1928        let registry = ModelRegistry::with_builtins();
1929        let models = registry.list().to_vec();
1930        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1931        let mut config = Config::default();
1932        let state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1933
1934        state.apply_to_config(&mut config);
1935
1936        assert_eq!(config.ui.sidebar_style, SidebarStyle::Inspector);
1937        assert_eq!(config.ui.tool_output, ToolOutputDisplay::Full);
1938        assert_eq!(config.ui.chat_tool_display, ChatToolDisplay::Summary);
1939        assert!(!config.ui.hide_tools_in_chat);
1940    }
1941
1942    #[test]
1943    fn save_field_scrolls_into_view_on_short_panels() {
1944        let registry = ModelRegistry::with_builtins();
1945        let models = registry.list().to_vec();
1946        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1947        let config = Config::default();
1948        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1949        state.tab = SettingsTab::Ui;
1950        state.selected = field_index(SettingsField::Save);
1951
1952        assert_eq!(selected_settings_row(&state), 18);
1953        assert_eq!(total_settings_rows(&state), 19);
1954        assert_eq!(settings_scroll_offset(&state, 10), 9);
1955    }
1956
1957    #[test]
1958    fn custom_theme_value_is_selectable_and_cycles() {
1959        let registry = ModelRegistry::with_builtins();
1960        let models = registry.list().to_vec();
1961        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1962        let config = Config {
1963            theme: Some("custom-highlighter".into()),
1964            ..Config::default()
1965        };
1966        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1967
1968        assert_eq!(state.theme_name, "custom-highlighter");
1969        assert!(state
1970            .theme_options
1971            .iter()
1972            .any(|theme| theme == "custom-highlighter"));
1973
1974        state.selected = field_index(SettingsField::Theme);
1975        state.cycle_forward();
1976        assert_eq!(state.theme_name, "default");
1977        state.cycle_backward();
1978        assert_eq!(state.theme_name, "custom-highlighter");
1979    }
1980
1981    #[test]
1982    fn top_fields_do_not_scroll_when_visible() {
1983        let registry = ModelRegistry::with_builtins();
1984        let models = registry.list().to_vec();
1985        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
1986        let config = Config::default();
1987        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
1988
1989        assert_eq!(selected_settings_row(&state), 4);
1990        assert_eq!(settings_scroll_offset(&state, 10), 0);
1991
1992        state.move_down();
1993        assert_eq!(selected_settings_row(&state), 5);
1994        assert_eq!(settings_scroll_offset(&state, 10), 0);
1995    }
1996
1997    #[test]
1998    fn current_field_clamps_stale_selection() {
1999        let registry = ModelRegistry::with_builtins();
2000        let models = registry.list().to_vec();
2001        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2002        let state = SettingsState {
2003            selected: usize::MAX,
2004            ..SettingsState::new(&Config::default(), &models[0].id, &models, &auth_store)
2005        };
2006
2007        assert_eq!(state.current_field(), SettingsField::Save);
2008    }
2009
2010    #[test]
2011    fn cycle_model_is_safe_with_empty_model_options() {
2012        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2013        let mut state = SettingsState::new(&Config::default(), "custom-model", &[], &auth_store);
2014        state.selected = 0;
2015        state.model_options.clear();
2016
2017        state.cycle_forward();
2018        state.cycle_backward();
2019
2020        assert_eq!(state.model, "custom-model");
2021    }
2022
2023    #[test]
2024    fn chosen_models_round_trip_into_config() {
2025        let registry = ModelRegistry::with_builtins();
2026        let models = registry.list().to_vec();
2027        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2028        let mut config = Config::default();
2029        let mut state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
2030
2031        state.tab = SettingsTab::Model;
2032        state.selected = field_index(SettingsField::ChosenModels);
2033        state.cycle_forward();
2034        assert_eq!(state.chosen_models, vec![models[0].id.clone()]);
2035
2036        state.apply_to_config(&mut config);
2037        assert_eq!(config.enabled_models, Some(vec![models[0].id.clone()]));
2038    }
2039
2040    #[test]
2041    fn bell_setting_round_trips_into_config() {
2042        let registry = ModelRegistry::with_builtins();
2043        let models = registry.list().to_vec();
2044        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2045        let mut config = Config::default();
2046        let state = SettingsState {
2047            notify_on_agent_complete: false,
2048            ..SettingsState::new(&config, &models[0].id, &models, &auth_store)
2049        };
2050
2051        state.apply_to_config(&mut config);
2052        assert!(!config.ui.notify_on_agent_complete);
2053    }
2054
2055    #[test]
2056    fn continue_policy_round_trips_into_config() {
2057        let registry = ModelRegistry::with_builtins();
2058        let models = registry.list().to_vec();
2059        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2060        let mut config = Config::default();
2061        let state = SettingsState {
2062            continue_policy: ContinuePolicy::Balanced,
2063            ..SettingsState::new(&config, &models[0].id, &models, &auth_store)
2064        };
2065
2066        state.apply_to_config(&mut config);
2067        assert_eq!(config.ui.continue_policy, ContinuePolicy::Balanced);
2068    }
2069
2070    #[test]
2071    fn empty_chosen_models_means_all_models() {
2072        let registry = ModelRegistry::with_builtins();
2073        let models = registry.list().to_vec();
2074        let auth_store = AuthStore::new(std::path::PathBuf::from("/tmp/auth.json"));
2075        let mut config = Config::default();
2076        let state = SettingsState::new(&config, &models[0].id, &models, &auth_store);
2077
2078        state.apply_to_config(&mut config);
2079        assert_eq!(config.enabled_models, None);
2080    }
2081}