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