edit_prediction_provider_setup.rs

  1use edit_prediction::{
  2    ApiKeyState, Zeta2FeatureFlag,
  3    mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
  4    sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token},
  5};
  6use feature_flags::FeatureFlagAppExt as _;
  7use gpui::{Entity, ScrollHandle, prelude::*};
  8use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
  9use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
 10
 11use crate::{
 12    SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
 13    components::{SettingsInputField, SettingsSectionHeader},
 14};
 15
 16pub struct EditPredictionSetupPage {
 17    settings_window: Entity<SettingsWindow>,
 18    scroll_handle: ScrollHandle,
 19}
 20
 21impl EditPredictionSetupPage {
 22    pub fn new(settings_window: Entity<SettingsWindow>) -> Self {
 23        Self {
 24            settings_window,
 25            scroll_handle: ScrollHandle::new(),
 26        }
 27    }
 28}
 29
 30impl Render for EditPredictionSetupPage {
 31    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 32        let settings_window = self.settings_window.clone();
 33
 34        let providers = [
 35            Some(render_github_copilot_provider(window, cx).into_any_element()),
 36            cx.has_flag::<Zeta2FeatureFlag>().then(|| {
 37                render_api_key_provider(
 38                    IconName::Inception,
 39                    "Mercury",
 40                    "https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
 41                    mercury_api_token(cx),
 42                    |_cx| MERCURY_CREDENTIALS_URL,
 43                    None,
 44                    window,
 45                    cx,
 46                )
 47                .into_any_element()
 48            }),
 49            cx.has_flag::<Zeta2FeatureFlag>().then(|| {
 50                render_api_key_provider(
 51                    IconName::SweepAi,
 52                    "Sweep",
 53                    "https://app.sweep.dev/".into(),
 54                    sweep_api_token(cx),
 55                    |_cx| SWEEP_CREDENTIALS_URL,
 56                    None,
 57                    window,
 58                    cx,
 59                )
 60                .into_any_element()
 61            }),
 62            Some(
 63                render_api_key_provider(
 64                    IconName::AiMistral,
 65                    "Codestral",
 66                    "https://console.mistral.ai/codestral".into(),
 67                    codestral_api_key(cx),
 68                    |cx| language_models::MistralLanguageModelProvider::api_url(cx),
 69                    Some(settings_window.update(cx, |settings_window, cx| {
 70                        let codestral_settings = codestral_settings();
 71                        settings_window
 72                            .render_sub_page_items_section(
 73                                codestral_settings.iter().enumerate(),
 74                                None,
 75                                window,
 76                                cx,
 77                            )
 78                            .into_any_element()
 79                    })),
 80                    window,
 81                    cx,
 82                )
 83                .into_any_element(),
 84            ),
 85        ];
 86
 87        div()
 88            .size_full()
 89            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
 90            .child(
 91                v_flex()
 92                    .id("ep-setup-page")
 93                    .min_w_0()
 94                    .size_full()
 95                    .px_8()
 96                    .pb_16()
 97                    .overflow_y_scroll()
 98                    .track_scroll(&self.scroll_handle)
 99                    .children(providers.into_iter().flatten()),
100            )
101    }
102}
103
104fn render_api_key_provider(
105    icon: IconName,
106    title: &'static str,
107    link: SharedString,
108    api_key_state: Entity<ApiKeyState>,
109    current_url: fn(&mut App) -> SharedString,
110    additional_fields: Option<AnyElement>,
111    window: &mut Window,
112    cx: &mut Context<EditPredictionSetupPage>,
113) -> impl IntoElement {
114    let weak_page = cx.weak_entity();
115    _ = window.use_keyed_state(title, cx, |_, cx| {
116        let task = api_key_state.update(cx, |key_state, cx| {
117            key_state.load_if_needed(current_url(cx), |state| state, cx)
118        });
119        cx.spawn(async move |_, cx| {
120            task.await.ok();
121            weak_page
122                .update(cx, |_, cx| {
123                    cx.notify();
124                })
125                .ok();
126        })
127    });
128
129    let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
130        (
131            state.has_key(),
132            Some(state.env_var_name().clone()),
133            state.is_from_env_var(),
134        )
135    });
136
137    let write_key = move |api_key: Option<String>, cx: &mut App| {
138        api_key_state
139            .update(cx, |key_state, cx| {
140                let url = current_url(cx);
141                key_state.store(url, api_key, |key_state| key_state, cx)
142            })
143            .detach_and_log_err(cx);
144    };
145
146    let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
147    let header = SettingsSectionHeader::new(title)
148        .icon(icon)
149        .no_padding(true);
150    let button_link_label = format!("{} dashboard", title);
151    let description = h_flex()
152        .min_w_0()
153        .gap_0p5()
154        .child(
155            Label::new("Visit the")
156                .size(LabelSize::Small)
157                .color(Color::Muted),
158        )
159        .child(
160            ButtonLink::new(button_link_label, link)
161                .no_icon(true)
162                .label_size(LabelSize::Small)
163                .label_color(Color::Muted),
164        )
165        .child(
166            Label::new("to generate an API key.")
167                .size(LabelSize::Small)
168                .color(Color::Muted),
169        );
170    let configured_card_label = if is_from_env_var {
171        "API Key Set in Environment Variable"
172    } else {
173        "API Key Configured"
174    };
175
176    let container = if has_key {
177        base_container.child(header).child(
178            ConfiguredApiCard::new(configured_card_label)
179                .button_label("Reset Key")
180                .button_tab_index(0)
181                .disabled(is_from_env_var)
182                .when_some(env_var_name, |this, env_var_name| {
183                    this.when(is_from_env_var, |this| {
184                        this.tooltip_label(format!(
185                            "To reset your API key, unset the {} environment variable.",
186                            env_var_name
187                        ))
188                    })
189                })
190                .on_click(move |_, _, cx| {
191                    write_key(None, cx);
192                }),
193        )
194    } else {
195        base_container.child(header).child(
196            h_flex()
197                .pt_2p5()
198                .w_full()
199                .justify_between()
200                .child(
201                    v_flex()
202                        .w_full()
203                        .max_w_1_2()
204                        .child(Label::new("API Key"))
205                        .child(description)
206                        .when_some(env_var_name, |this, env_var_name| {
207                            this.child({
208                                let label = format!(
209                                    "Or set the {} env var and restart Zed.",
210                                    env_var_name.as_ref()
211                                );
212                                Label::new(label).size(LabelSize::Small).color(Color::Muted)
213                            })
214                        }),
215                )
216                .child(
217                    SettingsInputField::new()
218                        .tab_index(0)
219                        .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
220                        .on_confirm(move |api_key, cx| {
221                            write_key(api_key.filter(|key| !key.is_empty()), cx);
222                        }),
223                ),
224        )
225    };
226
227    container.when_some(additional_fields, |this, additional_fields| {
228        this.child(
229            div()
230                .map(|this| if has_key { this.mt_1() } else { this.mt_4() })
231                .px_neg_8()
232                .border_t_1()
233                .border_color(cx.theme().colors().border_variant)
234                .child(additional_fields),
235        )
236    })
237}
238
239fn codestral_settings() -> Box<[SettingsPageItem]> {
240    Box::new([
241        SettingsPageItem::SettingItem(SettingItem {
242            title: "API URL",
243            description: "The API URL to use for Codestral.",
244            field: Box::new(SettingField {
245                pick: |settings| {
246                    settings
247                        .project
248                        .all_languages
249                        .edit_predictions
250                        .as_ref()?
251                        .codestral
252                        .as_ref()?
253                        .api_url
254                        .as_ref()
255                },
256                write: |settings, value| {
257                    settings
258                        .project
259                        .all_languages
260                        .edit_predictions
261                        .get_or_insert_default()
262                        .codestral
263                        .get_or_insert_default()
264                        .api_url = value;
265                },
266                json_path: Some("edit_predictions.codestral.api_url"),
267            }),
268            metadata: Some(Box::new(SettingsFieldMetadata {
269                placeholder: Some(CODESTRAL_API_URL),
270                ..Default::default()
271            })),
272            files: USER,
273        }),
274        SettingsPageItem::SettingItem(SettingItem {
275            title: "Max Tokens",
276            description: "The maximum number of tokens to generate.",
277            field: Box::new(SettingField {
278                pick: |settings| {
279                    settings
280                        .project
281                        .all_languages
282                        .edit_predictions
283                        .as_ref()?
284                        .codestral
285                        .as_ref()?
286                        .max_tokens
287                        .as_ref()
288                },
289                write: |settings, value| {
290                    settings
291                        .project
292                        .all_languages
293                        .edit_predictions
294                        .get_or_insert_default()
295                        .codestral
296                        .get_or_insert_default()
297                        .max_tokens = value;
298                },
299                json_path: Some("edit_predictions.codestral.max_tokens"),
300            }),
301            metadata: None,
302            files: USER,
303        }),
304        SettingsPageItem::SettingItem(SettingItem {
305            title: "Model",
306            description: "The Codestral model id to use.",
307            field: Box::new(SettingField {
308                pick: |settings| {
309                    settings
310                        .project
311                        .all_languages
312                        .edit_predictions
313                        .as_ref()?
314                        .codestral
315                        .as_ref()?
316                        .model
317                        .as_ref()
318                },
319                write: |settings, value| {
320                    settings
321                        .project
322                        .all_languages
323                        .edit_predictions
324                        .get_or_insert_default()
325                        .codestral
326                        .get_or_insert_default()
327                        .model = value;
328                },
329                json_path: Some("edit_predictions.codestral.model"),
330            }),
331            metadata: Some(Box::new(SettingsFieldMetadata {
332                placeholder: Some("codestral-latest"),
333                ..Default::default()
334            })),
335            files: USER,
336        }),
337    ])
338}
339
340pub(crate) fn render_github_copilot_provider(
341    window: &mut Window,
342    cx: &mut App,
343) -> impl IntoElement {
344    let configuration_view = window.use_state(cx, |_, cx| {
345        copilot::ConfigurationView::new(
346            |cx| {
347                copilot::Copilot::global(cx)
348                    .is_some_and(|copilot| copilot.read(cx).is_authenticated())
349            },
350            copilot::ConfigurationMode::EditPrediction,
351            cx,
352        )
353    });
354
355    v_flex()
356        .id("github-copilot")
357        .min_w_0()
358        .gap_1p5()
359        .child(
360            SettingsSectionHeader::new("GitHub Copilot")
361                .icon(IconName::Copilot)
362                .no_padding(true),
363        )
364        .child(configuration_view)
365}