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