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#[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#[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 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 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 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 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 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 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
1078pub 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#[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}