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