Skip to main content

ragrig_gpui/rag/
settings_page.rs

1//! The Ragrig settings pages.
2//!
3//! Hosts can take the complete page, or split it by concern: [`rag_model_page`]
4//! contains the model, retrieval, parsing, and memory groups, while
5//! [`rag_references_group`] can be added to a host's own library page.
6//!
7//! Rows are built with [`SettingRow`] rather than [`SettingItem::new`] so a
8//! field that differs from the applied configuration can be marked in bold
9//! violet. Pass a [`Changes`] closure to report those differences; a host that
10//! does not track them can pass [`no_changes`].
11
12use 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
39/// Reports which settings differ from the configuration applied to the worker.
40pub type Changes = Rc<dyn Fn(&App) -> RagChanges>;
41
42/// A [`Changes`] closure for hosts that do not track pending edits.
43pub fn no_changes() -> Changes {
44    Rc::new(|_| RagChanges::default())
45}
46
47/// Opens the effective prompt in the host editor.
48///
49/// `None` means no file is selected and the host should show its built-in
50/// prompt.
51pub type OpenPrompt = Rc<dyn Fn(Option<&Path>, &mut Window, &mut App)>;
52
53/// Writes the host's built-in prompt into the workspace and opens it.
54///
55/// Returns the new absolute path on success.
56pub type ExportPrompt = Rc<dyn Fn(&mut Window, &mut App) -> Result<String, String>>;
57
58/// Host actions for the system-prompt file row.
59///
60/// The generic settings page owns the path field, but only the host can open a
61/// file in its editor or write its compiled-in prompt into the workspace.
62#[derive(Clone)]
63pub struct PromptActions {
64    /// File name of the host's compiled-in prompt, used in labels and help.
65    pub built_in_name: &'static str,
66    /// Open the prompt in the host editor; `None` is the built-in prompt.
67    pub open: OpenPrompt,
68    /// Write the built-in prompt into the workspace, select it, and open it.
69    pub export: ExportPrompt,
70}
71
72/// Build the complete Ragrig settings page.
73///
74/// The Apply row validates [`RagSettings`] and asks the [`RagController`] to
75/// rebuild the agent. Profile actions are filesystem-only; they do not touch
76/// the running agent until Apply is pressed.
77pub 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
101/// The model, retrieval, parsing, and memory groups.
102///
103/// This is the RAG page for a host that keeps workspace, bibliography,
104/// profile management, and reference ingestion on its own pages. Pass
105/// [`PromptActions`] to add the open and export buttons beside the prompt file
106/// field; `None` renders the path field alone.
107pub 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
138/// The workspace and bibliography group for hosts that use Ragrig's page.
139fn 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
600/// The reference-ingestion group for a host's library page.
601///
602/// The GROBID server row belongs to the host's **External services** page;
603/// hosts that show that page can use [`rag_reference_processing_group`] to
604/// avoid a duplicate URL field.
605pub 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
632/// The reference-processing rows for a host that manages the GROBID server on
633/// its own **External services** page.
634pub 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
729/// Build a complete [`Settings`] element for Ragrig configuration.
730pub 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
864/// The page title, with a bullet while a section has unsaved changes.
865pub(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
873/// Append the "unsaved changes" footer to a group that differs from the agent.
874fn 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/// The identity of one settings row: its keyed-state key, change field, and
888/// visible title.
889#[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}