1use std::{path::Path, rc::Rc};
13
14use gpui_kit::{
15 AnyElement, App, ElementId, Entity, FontWeight, IntoElement, ParentElement as _, SharedString,
16 Styled as _, Window,
17 component::{
18 ActiveTheme as _, Sizable as _,
19 button::{Button, ButtonVariants as _},
20 h_flex,
21 setting::{
22 AnySettingField, NumberFieldOptions, SettingField, SettingGroup, SettingItem,
23 SettingPage, Settings,
24 },
25 },
26 div,
27 prelude::FluentBuilder as _,
28};
29
30use super::{
31 ChatProvider, MemoryChoice, PdfBackend, RagChanges, RagController, RagField, RagSection,
32 RagSettings,
33};
34use crate::{
35 SettingRow, dirty_color, dirty_header, dropdown_control, number_control, path_control,
36 path_setting, switch_control, text_control, token_control,
37};
38
39pub type Changes = Rc<dyn Fn(&App) -> RagChanges>;
41
42pub fn no_changes() -> Changes {
44 Rc::new(|_| RagChanges::default())
45}
46
47pub type OpenPrompt = Rc<dyn Fn(Option<&Path>, &mut Window, &mut App)>;
52
53pub type ExportPrompt = Rc<dyn Fn(&mut Window, &mut App) -> Result<String, String>>;
57
58#[derive(Clone)]
63pub struct PromptActions {
64 pub built_in_name: &'static str,
66 pub open: OpenPrompt,
68 pub export: ExportPrompt,
70}
71
72pub fn rag_settings_page(
78 settings: Entity<RagSettings>,
79 controller: Entity<RagController>,
80 cx: &App,
81) -> SettingPage {
82 let changes = no_changes();
83 let now = RagChanges::default();
84 let mut page = SettingPage::new("RAG")
85 .description(
86 "Ragrig workspace, models, retrieval, and memory. Press Apply to rebuild the agent.",
87 )
88 .default_open(true)
89 .group(workspace_group(&settings))
90 .group(chat_group(&settings, &changes, &now, None))
91 .group(retrieval_group(&settings, &changes, &now))
92 .group(parsing_group(&settings, &changes, &now))
93 .group(memory_group(&settings, &changes, &now))
94 .group(rag_references_group(&settings, &changes));
95 page = page
96 .group(profiles_group(&settings, cx))
97 .group(apply_group(&settings, &controller));
98 page
99}
100
101pub fn rag_model_page(
108 settings: Entity<RagSettings>,
109 changes: Changes,
110 prompt: Option<PromptActions>,
111 cx: &App,
112) -> SettingPage {
113 let now = changes(cx);
114 let page_dirty = [
115 RagSection::Chat,
116 RagSection::Retrieval,
117 RagSection::Parsing,
118 RagSection::Memory,
119 ]
120 .iter()
121 .any(|section| now.section(*section));
122 let page = SettingPage::new(group_title("RAG", page_dirty))
123 .description(
124 "Models, retrieval, parsing, and memory. Press Apply on Overview to rebuild the agent.",
125 )
126 .default_open(true);
127 let page = if page_dirty {
128 page.header_style(&dirty_header(cx))
129 } else {
130 page
131 };
132 page.group(chat_group(&settings, &changes, &now, prompt.as_ref()))
133 .group(retrieval_group(&settings, &changes, &now))
134 .group(parsing_group(&settings, &changes, &now))
135 .group(memory_group(&settings, &changes, &now))
136}
137
138fn workspace_group(settings: &Entity<RagSettings>) -> SettingGroup {
140 let settings_for_workspace = settings.clone();
141 let workspace = path_setting(
142 "rag-workspace",
143 "Choose the Ragrig workspace",
144 true,
145 false,
146 {
147 let settings = settings.clone();
148 move |cx| settings.read(cx).workspace.clone()
149 },
150 move |value, cx| {
151 settings_for_workspace.update(cx, |settings, cx| {
152 settings.workspace = value;
153 cx.notify();
154 });
155 },
156 );
157 let settings_for_bibliography = settings.clone();
158 let bibliography = path_setting(
159 "rag-bibliography",
160 "Choose the bibliography file",
161 false,
162 true,
163 {
164 let settings = settings.clone();
165 move |cx| settings.read(cx).bibliography.clone()
166 },
167 move |value, cx| {
168 settings_for_bibliography.update(cx, |settings, cx| {
169 settings.bibliography = value;
170 cx.notify();
171 });
172 },
173 );
174
175 SettingGroup::new()
176 .title("Workspace")
177 .item(
178 SettingItem::new("Workspace", workspace)
179 .description("Store, sessions, and profiles live under this folder (.ragrig)."),
180 )
181 .item(SettingItem::new("Bibliography", bibliography).description(
182 "The .bib the library reads and GROBID writes. Empty uses .ragrig/references.bib.",
183 ))
184}
185
186fn chat_group(
187 settings: &Entity<RagSettings>,
188 changes: &Changes,
189 now: &RagChanges,
190 prompt: Option<&PromptActions>,
191) -> SettingGroup {
192 let provider = dropdown_row(
193 settings,
194 changes,
195 spec("rag-provider", RagField::Provider, "Provider"),
196 move |_| provider_options(),
197 |settings| settings.provider.label().to_owned(),
198 |settings, value| {
199 settings.provider = ChatProvider::from_label(&value).unwrap_or(settings.provider);
200 },
201 )
202 .description(format!(
203 "Cloud keys: {}. A missing key is reported when the agent starts.",
204 ChatProvider::options()
205 .iter()
206 .filter_map(|(provider, _)| provider.env_var())
207 .collect::<Vec<_>>()
208 .join(", ")
209 ));
210 let model = text_row(
211 settings,
212 changes,
213 spec("rag-model", RagField::Model, "Model"),
214 |settings| settings.model().to_owned(),
215 |settings, value| settings.set_model(value),
216 )
217 .description("Used by the selected provider; each provider keeps its own model.");
218 let stream = switch_row(
219 settings,
220 changes,
221 spec("rag-stream", RagField::Stream, "Stream responses"),
222 |settings| settings.stream,
223 |settings, value| settings.stream = value,
224 );
225 let temperature = number_row(
226 settings,
227 changes,
228 spec("rag-temperature", RagField::Temperature, "Temperature"),
229 NumberFieldOptions {
230 min: 0.0,
231 max: 2.0,
232 step: 0.1,
233 },
234 |settings| settings.temperature,
235 |settings, value| settings.temperature = value.clamp(0.0, 2.0),
236 )
237 .description("0 is deterministic; 2 is very loose.");
238 let max_tokens = token_row(
239 settings,
240 changes,
241 spec("rag-max-tokens", RagField::MaxTokens, "Maximum tokens"),
242 9,
243 20,
244 |settings| settings.max_tokens,
245 |settings, value| settings.max_tokens = value,
246 );
247 let context_tokens = token_row(
248 settings,
249 changes,
250 spec(
251 "rag-context-tokens",
252 RagField::ContextTokens,
253 "Context tokens",
254 ),
255 9,
256 20,
257 |settings| settings.context_tokens,
258 |settings, value| settings.context_tokens = value,
259 );
260 let prompt_path = path_control(
261 "rag-system-prompt-path",
262 "Choose a Markdown prompt file",
263 false,
264 true,
265 {
266 let settings = settings.clone();
267 move |cx| settings.read(cx).system_prompt_path.clone()
268 },
269 {
270 let settings = settings.clone();
271 move |value, cx| {
272 settings.update(cx, |settings, cx| {
273 settings.system_prompt_path = value;
274 cx.notify();
275 });
276 }
277 },
278 );
279 let built_in_name = prompt.map_or("built-in prompt", |prompt| prompt.built_in_name);
280 let system_prompt = SettingRow::new("System prompt", prompt_path)
281 .description(format!(
282 "Empty uses the {built_in_name}. A selected Markdown file overrides it; Apply rebuilds the agent."
283 ))
284 .dirty({
285 let changes = changes.clone();
286 move |cx| changes(cx).contains(RagField::SystemPrompt)
287 })
288 .into_item();
289
290 let mut group = SettingGroup::new()
291 .title(group_title("Chat", now.section(RagSection::Chat)))
292 .item(provider.into_item())
293 .item(model.into_item())
294 .item(stream.into_item())
295 .item(temperature.into_item())
296 .item(max_tokens.into_item())
297 .item(context_tokens.into_item())
298 .item(system_prompt);
299 if let Some(prompt) = prompt {
300 let settings = settings.clone();
301 let prompt = prompt.clone();
302 group = group.item(SettingItem::render(move |_, window, cx| {
303 prompt_buttons(settings.clone(), prompt.clone(), window, cx)
304 }));
305 }
306
307 mark_group(group, now, RagSection::Chat)
308}
309
310fn prompt_buttons(
311 settings: Entity<RagSettings>,
312 actions: PromptActions,
313 _: &mut Window,
314 cx: &mut App,
315) -> AnyElement {
316 let built_in = settings.read(cx).built_in_prompt.clone();
317 let path = settings.read(cx).system_prompt_path.clone();
318 let status = settings.read(cx).status.clone();
319 let uses_built_in =
320 path.trim().is_empty() && settings.read(cx).system_prompt.trim() == built_in.trim();
321
322 let open = {
323 let settings = settings.clone();
324 let actions = actions.clone();
325 Button::new("rag-prompt-open")
326 .label("Open prompt")
327 .icon(gpui_kit::assets::IconName::FolderOpen)
328 .small()
329 .outline()
330 .on_click(move |_, window, cx| {
331 let path = settings.read(cx).prompt_path();
332 (actions.open)(path.as_deref(), window, cx);
333 })
334 };
335 let export = {
336 let settings = settings.clone();
337 let actions = actions.clone();
338 let label = format!("Export {}", actions.built_in_name);
339 let built_in_name = actions.built_in_name;
340 Button::new("rag-prompt-export")
341 .label(label)
342 .icon(gpui_kit::assets::IconName::Save)
343 .small()
344 .outline()
345 .on_click(move |_, window, cx| match (actions.export)(window, cx) {
346 Ok(path) => settings.update(cx, |settings, cx| {
347 settings.system_prompt_path = path;
348 settings.status = Some(format!(
349 "Using {built_in_name} — edit it, then press Apply to rebuild the agent"
350 ));
351 cx.notify();
352 }),
353 Err(message) => settings.update(cx, |settings, cx| {
354 settings.status = Some(message);
355 cx.notify();
356 }),
357 })
358 };
359 let reset = (!uses_built_in).then(|| {
360 let settings = settings.clone();
361 Button::new("rag-prompt-built-in")
362 .label("Use built-in")
363 .icon(gpui_kit::assets::IconName::RefreshCw)
364 .small()
365 .ghost()
366 .on_click(move |_, _, cx| {
367 settings.update(cx, |settings, cx| {
368 settings.use_built_in_prompt();
369 cx.notify();
370 });
371 })
372 });
373
374 h_flex()
375 .gap_2()
376 .items_center()
377 .child(open)
378 .child(export)
379 .when_some(reset, |row, reset| row.child(reset))
380 .when_some(status, |row, status| {
381 row.child(
382 div()
383 .text_xs()
384 .text_color(cx.theme().muted_foreground)
385 .child(status),
386 )
387 })
388 .into_any_element()
389}
390
391fn retrieval_group(
392 settings: &Entity<RagSettings>,
393 changes: &Changes,
394 now: &RagChanges,
395) -> SettingGroup {
396 let hybrid = switch_row(
397 settings,
398 changes,
399 spec("rag-hybrid", RagField::Hybrid, "Hybrid retrieval"),
400 |settings| settings.hybrid,
401 |settings, value| settings.hybrid = value,
402 )
403 .description("BM25 plus embeddings. Off skips vector search and needs no embedding model.");
404 let embed_model = text_row(
405 settings,
406 changes,
407 spec("rag-embed-model", RagField::EmbedModel, "Embedding model"),
408 |settings| settings.embed_model.clone(),
409 |settings, value| settings.embed_model = value,
410 );
411 let top_k = number_row(
412 settings,
413 changes,
414 spec("rag-top-k", RagField::TopK, "Top-k chunks"),
415 NumberFieldOptions {
416 min: 1.0,
417 max: 500.0,
418 step: 1.0,
419 },
420 |settings| settings.top_k as f64,
421 |settings, value| settings.top_k = value.round().clamp(1.0, 500.0) as usize,
422 );
423 let threshold = number_row(
424 settings,
425 changes,
426 spec(
427 "rag-threshold",
428 RagField::SimilarityThreshold,
429 "Similarity threshold",
430 ),
431 NumberFieldOptions {
432 min: 0.0,
433 max: 1.0,
434 step: 0.01,
435 },
436 |settings| settings.similarity_threshold,
437 |settings, value| settings.similarity_threshold = value.clamp(0.0, 1.0),
438 )
439 .description("Chunks below this score are dropped.");
440 let corpus_dirs = text_row(
441 settings,
442 changes,
443 spec("rag-corpus-dirs", RagField::CorpusDirs, "Folder corpora"),
444 |settings| settings.corpus_dirs.join(", "),
445 |settings, value| {
446 settings.corpus_dirs = value
447 .split(',')
448 .map(str::trim)
449 .filter(|entry| !entry.is_empty())
450 .map(str::to_owned)
451 .collect();
452 },
453 )
454 .description("Comma-separated name=path pairs.")
455 .vertical();
456
457 mark_group(
458 SettingGroup::new()
459 .title(group_title("Retrieval", now.section(RagSection::Retrieval)))
460 .item(hybrid.into_item())
461 .item(embed_model.into_item())
462 .item(top_k.into_item())
463 .item(threshold.into_item())
464 .item(corpus_dirs.into_item()),
465 now,
466 RagSection::Retrieval,
467 )
468}
469
470fn parsing_group(
471 settings: &Entity<RagSettings>,
472 changes: &Changes,
473 now: &RagChanges,
474) -> SettingGroup {
475 let pdf_options: Vec<(SharedString, SharedString)> = PdfBackend::options()
476 .iter()
477 .map(|(backend, label)| {
478 (
479 SharedString::from(backend.label()),
480 SharedString::from(*label),
481 )
482 })
483 .collect();
484 let pdf_backend = dropdown_row(
485 settings,
486 changes,
487 spec("rag-pdf-backend", RagField::PdfBackend, "PDF backend"),
488 move |_| pdf_options.clone(),
489 |settings| settings.pdf_backend.label().to_owned(),
490 |settings, value| {
491 settings.pdf_backend = PdfBackend::options()
492 .iter()
493 .find(|(backend, _)| backend.label() == value)
494 .map_or(PdfBackend::Extract, |(backend, _)| *backend);
495 },
496 )
497 .description("Primary parser for PDF documents.");
498 let sloppy_pdf = switch_row(
499 settings,
500 changes,
501 spec("rag-sloppy-pdf", RagField::SloppyPdf, "Sloppy fallback"),
502 |settings| settings.sloppy_pdf,
503 |settings, value| settings.sloppy_pdf = value,
504 )
505 .description("Keep the panic-free PDF scavenger as a fallback.");
506 let chunk_size = number_row(
507 settings,
508 changes,
509 spec("rag-chunk-size", RagField::ChunkSize, "Chunk size"),
510 NumberFieldOptions {
511 min: 16.0,
512 max: 32_768.0,
513 step: 16.0,
514 },
515 |settings| settings.chunk_size as f64,
516 |settings, value| settings.chunk_size = value.round().clamp(16.0, 32_768.0) as usize,
517 );
518 let chunk_overlap = number_row(
519 settings,
520 changes,
521 spec("rag-chunk-overlap", RagField::ChunkOverlap, "Chunk overlap"),
522 NumberFieldOptions {
523 min: 0.0,
524 max: 8_192.0,
525 step: 8.0,
526 },
527 |settings| settings.chunk_overlap as f64,
528 |settings, value| {
529 settings.chunk_overlap = value.round().clamp(0.0, 8_192.0) as usize;
530 },
531 );
532
533 mark_group(
534 SettingGroup::new()
535 .title(group_title(
536 "Parsing and chunking",
537 now.section(RagSection::Parsing),
538 ))
539 .item(pdf_backend.into_item())
540 .item(sloppy_pdf.into_item())
541 .item(chunk_size.into_item())
542 .item(chunk_overlap.into_item())
543 .footer(|_, cx| {
544 div()
545 .text_xs()
546 .text_color(cx.theme().muted_foreground)
547 .child("Changing chunking only affects new ingestion, not stored chunks.")
548 }),
549 now,
550 RagSection::Parsing,
551 )
552}
553
554fn memory_group(
555 settings: &Entity<RagSettings>,
556 changes: &Changes,
557 now: &RagChanges,
558) -> SettingGroup {
559 let memory_options: Vec<(SharedString, SharedString)> = MemoryChoice::options()
560 .iter()
561 .map(|(choice, label)| {
562 (
563 SharedString::from(choice.label()),
564 SharedString::from(*label),
565 )
566 })
567 .collect();
568 let memory = dropdown_row(
569 settings,
570 changes,
571 spec("rag-memory", RagField::Memory, "Strategy"),
572 move |_| memory_options.clone(),
573 |settings| settings.memory.label().to_owned(),
574 |settings, value| {
575 settings.memory = MemoryChoice::options()
576 .iter()
577 .find(|(choice, _)| choice.label() == value)
578 .map_or(MemoryChoice::Rewrite, |(choice, _)| *choice);
579 },
580 );
581 let memory_model = text_row(
582 settings,
583 changes,
584 spec("rag-memory-model", RagField::MemoryModel, "Memory model"),
585 |settings| settings.memory_model.clone(),
586 |settings, value| settings.memory_model = value,
587 )
588 .description("Small local model used for rewriting and summaries.");
589
590 mark_group(
591 SettingGroup::new()
592 .title(group_title("Memory", now.section(RagSection::Memory)))
593 .item(memory.into_item())
594 .item(memory_model.into_item()),
595 now,
596 RagSection::Memory,
597 )
598}
599
600pub fn rag_references_group(settings: &Entity<RagSettings>, changes: &Changes) -> SettingGroup {
606 let grobid_url = text_row(
607 settings,
608 changes,
609 spec("rag-grobid-url", RagField::GrobidUrl, "GROBID server"),
610 |settings| settings.grobid_url.clone(),
611 |settings, value| settings.grobid_url = value,
612 )
613 .description("Used when parsing a folder's PDFs into the bibliography.");
614 let (rename_pdfs, openalex, resolve_citations) = reference_processing_rows(settings, changes);
615
616 SettingGroup::new()
617 .title("References")
618 .item(grobid_url.into_item())
619 .item(rename_pdfs.into_item())
620 .item(openalex.into_item())
621 .item(resolve_citations.into_item())
622 .footer(|_, cx| {
623 div()
624 .text_xs()
625 .text_color(cx.theme().muted_foreground)
626 .child(
627 "Records merge into the Library bibliography (default .ragrig/references.bib).",
628 )
629 })
630}
631
632pub fn rag_reference_processing_group(
635 settings: &Entity<RagSettings>,
636 changes: &Changes,
637) -> SettingGroup {
638 let (rename_pdfs, openalex, resolve_citations) = reference_processing_rows(settings, changes);
639
640 SettingGroup::new()
641 .title("References")
642 .item(rename_pdfs.into_item())
643 .item(openalex.into_item())
644 .item(resolve_citations.into_item())
645 .footer(|_, cx| {
646 div()
647 .text_xs()
648 .text_color(cx.theme().muted_foreground)
649 .child(
650 "Records merge into the Library bibliography (default .ragrig/references.bib).",
651 )
652 })
653}
654
655fn reference_processing_rows(
656 settings: &Entity<RagSettings>,
657 changes: &Changes,
658) -> (SettingRow, SettingRow, SettingRow) {
659 let rename_pdfs = switch_row(
660 settings,
661 changes,
662 spec("rag-rename-pdfs", RagField::RenamePdfs, "Rename PDFs"),
663 |settings| settings.rename_pdfs,
664 |settings, value| settings.rename_pdfs = value,
665 )
666 .description("Rename processed PDFs to Key - Authors - Title - Year.");
667 let openalex = switch_row(
668 settings,
669 changes,
670 spec("rag-openalex", RagField::OpenAlex, "OpenAlex completion"),
671 |settings| settings.openalex,
672 |settings, value| settings.openalex = value,
673 )
674 .description("Fill missing DOI and venue data from OpenAlex.");
675 let resolve_citations = switch_row(
676 settings,
677 changes,
678 spec(
679 "rag-resolve-citations",
680 RagField::ResolveCitations,
681 "Use @key citations",
682 ),
683 |settings| settings.resolve_citations,
684 |settings, value| settings.resolve_citations = value,
685 )
686 .description("Parse @key papers in a question and attach them as context.");
687
688 (rename_pdfs, openalex, resolve_citations)
689}
690
691fn profiles_group(settings: &Entity<RagSettings>, cx: &App) -> SettingGroup {
692 let profile_name = input_field(
693 settings,
694 |settings| settings.profile_name.clone(),
695 |settings, value| settings.profile_name = value,
696 );
697 let profile_choice = dropdown_field(
698 settings,
699 profile_options(settings, cx),
700 |settings| settings.profile_name.clone(),
701 |settings, value| settings.profile_name = value,
702 );
703 let profile_actions = SettingItem::render({
704 let settings = settings.clone();
705 move |_, _, cx| profile_buttons(settings.clone(), cx)
706 });
707
708 SettingGroup::new()
709 .title("Profiles")
710 .item(SettingItem::new("Profile name", profile_name))
711 .item(SettingItem::new("Load profile", profile_choice))
712 .item(profile_actions)
713 .footer(|_, cx| {
714 div()
715 .text_xs()
716 .text_color(cx.theme().muted_foreground)
717 .child("Profiles are JSON files in .ragrig/profiles; secrets are never written.")
718 })
719}
720
721fn apply_group(settings: &Entity<RagSettings>, controller: &Entity<RagController>) -> SettingGroup {
722 let settings = settings.clone();
723 let controller = controller.clone();
724 let apply =
725 SettingItem::render(move |_, _, cx| apply_row(settings.clone(), controller.clone(), cx));
726 SettingGroup::new().title("Apply").item(apply)
727}
728
729pub fn rag_settings(
731 id: impl Into<ElementId>,
732 settings: Entity<RagSettings>,
733 controller: Entity<RagController>,
734 cx: &App,
735) -> Settings {
736 Settings::new(id).page(rag_settings_page(settings, controller, cx))
737}
738
739fn profile_buttons(settings: Entity<RagSettings>, cx: &mut App) -> AnyElement {
740 let save = {
741 let settings = settings.clone();
742 Button::new("rag-profile-save")
743 .label("Save")
744 .icon(gpui_kit::assets::IconName::Save)
745 .small()
746 .outline()
747 .on_click(move |_, _, cx| {
748 settings.update(cx, |settings, cx| {
749 if let Err(message) = settings.save_profile() {
750 settings.status = Some(message);
751 }
752 cx.notify();
753 });
754 })
755 };
756 let load = {
757 let settings = settings.clone();
758 Button::new("rag-profile-load")
759 .label("Load")
760 .icon(gpui_kit::assets::IconName::FolderOpen)
761 .small()
762 .outline()
763 .on_click(move |_, _, cx| {
764 let name = settings.read(cx).profile_name.trim().to_owned();
765 if name.is_empty() {
766 settings.update(cx, |settings, cx| {
767 settings.status = Some("choose a profile first".to_owned());
768 cx.notify();
769 });
770 return;
771 }
772 settings.update(cx, |settings, cx| {
773 if let Err(message) = settings.load_profile(&name) {
774 settings.status = Some(message);
775 }
776 cx.notify();
777 });
778 })
779 };
780 let refresh = {
781 let settings = settings.clone();
782 Button::new("rag-profile-refresh")
783 .label("Refresh")
784 .icon(gpui_kit::assets::IconName::RefreshCw)
785 .small()
786 .ghost()
787 .on_click(move |_, _, cx| {
788 settings.update(cx, |settings, cx| {
789 settings.refresh_profiles();
790 cx.notify();
791 });
792 })
793 };
794
795 let status = settings.read(cx).status.clone();
796 h_flex()
797 .gap_2()
798 .items_center()
799 .child(save)
800 .child(load)
801 .child(refresh)
802 .when_some(status, |row, status| {
803 row.child(
804 div()
805 .text_sm()
806 .text_color(cx.theme().muted_foreground)
807 .child(status),
808 )
809 })
810 .into_any_element()
811}
812
813fn apply_row(
814 settings: Entity<RagSettings>,
815 controller: Entity<RagController>,
816 cx: &mut App,
817) -> AnyElement {
818 let apply = {
819 let settings = settings.clone();
820 let controller = controller.clone();
821 Button::new("rag-settings-apply")
822 .label("Apply")
823 .icon(gpui_kit::assets::IconName::Play)
824 .primary()
825 .small()
826 .on_click(move |_, _, cx| match settings.read(cx).to_setup() {
827 Ok(setup) => {
828 controller.update(cx, |controller, cx| controller.apply(setup, cx));
829 settings.update(cx, |settings, cx| {
830 settings.status = None;
831 cx.notify();
832 });
833 }
834 Err(message) => {
835 settings.update(cx, |settings, cx| {
836 settings.status = Some(message);
837 cx.notify();
838 });
839 }
840 })
841 };
842
843 let controller_status = controller.read(cx).status_line();
844 let has_error = controller.read(cx).error().is_some();
845 let status = settings
846 .read(cx)
847 .status
848 .clone()
849 .unwrap_or(controller_status);
850 let color = if has_error {
851 cx.theme().danger
852 } else {
853 cx.theme().muted_foreground
854 };
855
856 h_flex()
857 .gap_3()
858 .items_center()
859 .child(apply)
860 .child(div().text_sm().text_color(color).child(status))
861 .into_any_element()
862}
863
864pub(crate) fn group_title(label: &str, dirty: bool) -> SharedString {
866 if dirty {
867 format!("{label} ●").into()
868 } else {
869 SharedString::from(label)
870 }
871}
872
873fn mark_group(group: SettingGroup, now: &RagChanges, section: RagSection) -> SettingGroup {
875 if !now.section(section) {
876 return group;
877 }
878 group.footer(|_, cx| {
879 div()
880 .text_xs()
881 .font_weight(FontWeight::BOLD)
882 .text_color(dirty_color(cx))
883 .child("● Unsaved changes — press Apply on Overview")
884 })
885}
886
887#[derive(Clone, Copy)]
890struct FieldSpec {
891 key: &'static str,
892 field: RagField,
893 title: &'static str,
894}
895
896fn spec(key: &'static str, field: RagField, title: &'static str) -> FieldSpec {
897 FieldSpec { key, field, title }
898}
899
900fn dirty(changes: &Changes, field: RagField) -> impl Fn(&App) -> bool + 'static {
901 let changes = changes.clone();
902 move |cx| changes(cx).contains(field)
903}
904
905fn text_row<R, W>(
906 settings: &Entity<RagSettings>,
907 changes: &Changes,
908 spec: FieldSpec,
909 read: R,
910 write: W,
911) -> SettingRow
912where
913 R: Fn(&RagSettings) -> String + 'static,
914 W: Fn(&mut RagSettings, String) + 'static,
915{
916 let reader = settings.clone();
917 let writer = settings.clone();
918 let control = text_control(
919 spec.key,
920 move |cx| read(reader.read(cx)),
921 move |value, cx| {
922 writer.update(cx, |settings, cx| {
923 write(settings, value);
924 cx.notify();
925 });
926 },
927 );
928 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
929}
930
931fn number_row<R, W>(
932 settings: &Entity<RagSettings>,
933 changes: &Changes,
934 spec: FieldSpec,
935 options: NumberFieldOptions,
936 read: R,
937 write: W,
938) -> SettingRow
939where
940 R: Fn(&RagSettings) -> f64 + 'static,
941 W: Fn(&mut RagSettings, f64) + 'static,
942{
943 let reader = settings.clone();
944 let writer = settings.clone();
945 let control = number_control(
946 spec.key,
947 options,
948 move |cx| read(reader.read(cx)),
949 move |value, cx| {
950 writer.update(cx, |settings, cx| {
951 write(settings, value);
952 cx.notify();
953 });
954 },
955 );
956 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
957}
958
959fn switch_row<R, W>(
960 settings: &Entity<RagSettings>,
961 changes: &Changes,
962 spec: FieldSpec,
963 read: R,
964 write: W,
965) -> SettingRow
966where
967 R: Fn(&RagSettings) -> bool + 'static,
968 W: Fn(&mut RagSettings, bool) + 'static,
969{
970 let reader = settings.clone();
971 let writer = settings.clone();
972 let control = switch_control(
973 spec.key,
974 move |cx| read(reader.read(cx)),
975 move |value, cx| {
976 writer.update(cx, |settings, cx| {
977 write(settings, value);
978 cx.notify();
979 });
980 },
981 );
982 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
983}
984
985fn dropdown_row<O, R, W>(
986 settings: &Entity<RagSettings>,
987 changes: &Changes,
988 spec: FieldSpec,
989 options: O,
990 read: R,
991 write: W,
992) -> SettingRow
993where
994 O: Fn(&App) -> Vec<(SharedString, SharedString)> + 'static,
995 R: Fn(&RagSettings) -> String + 'static,
996 W: Fn(&mut RagSettings, String) + 'static,
997{
998 let reader = settings.clone();
999 let writer = settings.clone();
1000 let control = dropdown_control(
1001 spec.key,
1002 options,
1003 move |cx| read(reader.read(cx)),
1004 move |value, cx| {
1005 writer.update(cx, |settings, cx| {
1006 write(settings, value);
1007 cx.notify();
1008 });
1009 },
1010 );
1011 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
1012}
1013
1014fn token_row<R, W>(
1015 settings: &Entity<RagSettings>,
1016 changes: &Changes,
1017 spec: FieldSpec,
1018 min_exponent: u32,
1019 max_exponent: u32,
1020 read: R,
1021 write: W,
1022) -> SettingRow
1023where
1024 R: Fn(&RagSettings) -> usize + 'static,
1025 W: Fn(&mut RagSettings, usize) + 'static,
1026{
1027 let reader = settings.clone();
1028 let writer = settings.clone();
1029 let control = token_control(
1030 spec.key,
1031 min_exponent,
1032 max_exponent,
1033 move |cx| read(reader.read(cx)),
1034 move |value, cx| {
1035 writer.update(cx, |settings, cx| {
1036 write(settings, value);
1037 cx.notify();
1038 });
1039 },
1040 );
1041 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
1042}
1043
1044fn profile_options(settings: &Entity<RagSettings>, cx: &App) -> Vec<(SharedString, SharedString)> {
1045 settings
1046 .read(cx)
1047 .profiles
1048 .iter()
1049 .filter(|name| !name.is_empty())
1050 .map(|name| {
1051 (
1052 SharedString::from(name.clone()),
1053 SharedString::from(name.clone()),
1054 )
1055 })
1056 .collect()
1057}
1058
1059fn input_field<R, W>(
1060 settings: &Entity<RagSettings>,
1061 read: R,
1062 write: W,
1063) -> impl AnySettingField + use<R, W>
1064where
1065 R: Fn(&RagSettings) -> String + 'static,
1066 W: Fn(&mut RagSettings, String) + 'static,
1067{
1068 let reader = settings.clone();
1069 let writer = settings.clone();
1070 SettingField::input(
1071 move |cx: &App| SharedString::from(read(reader.read(cx))),
1072 move |value: SharedString, cx: &mut App| {
1073 writer.update(cx, |settings, cx| {
1074 write(settings, value.to_string());
1075 cx.notify();
1076 });
1077 },
1078 )
1079}
1080
1081fn dropdown_field<R, W>(
1082 settings: &Entity<RagSettings>,
1083 options: Vec<(SharedString, SharedString)>,
1084 read: R,
1085 write: W,
1086) -> impl AnySettingField + use<R, W>
1087where
1088 R: Fn(&RagSettings) -> String + 'static,
1089 W: Fn(&mut RagSettings, String) + 'static,
1090{
1091 let reader = settings.clone();
1092 let writer = settings.clone();
1093 SettingField::dropdown(
1094 options,
1095 move |cx: &App| SharedString::from(read(reader.read(cx))),
1096 move |value: SharedString, cx: &mut App| {
1097 writer.update(cx, |settings, cx| {
1098 write(settings, value.to_string());
1099 cx.notify();
1100 });
1101 },
1102 )
1103}
1104
1105fn provider_options() -> Vec<(SharedString, SharedString)> {
1106 ChatProvider::options()
1107 .iter()
1108 .map(|(provider, label)| {
1109 (
1110 SharedString::from(provider.label()),
1111 SharedString::from(*label),
1112 )
1113 })
1114 .collect()
1115}