1use std::{cmp::Reverse, rc::Rc, sync::Arc};
2
3use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector};
4use agent_client_protocol::ModelId;
5use agent_servers::AgentServer;
6use anyhow::Result;
7use collections::{HashSet, IndexMap};
8use fs::Fs;
9use futures::FutureExt;
10use fuzzy::{StringMatchCandidate, match_strings};
11use gpui::{
12 Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
13 WeakEntity,
14};
15use itertools::Itertools;
16use ordered_float::OrderedFloat;
17use picker::{Picker, PickerDelegate};
18use settings::SettingsStore;
19use ui::{DocumentationAside, DocumentationSide, IntoElement, prelude::*};
20use util::ResultExt;
21use zed_actions::agent::OpenSettings;
22
23use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
24
25pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
26
27pub fn acp_model_selector(
28 selector: Rc<dyn AgentModelSelector>,
29 agent_server: Rc<dyn AgentServer>,
30 fs: Arc<dyn Fs>,
31 focus_handle: FocusHandle,
32 window: &mut Window,
33 cx: &mut Context<AcpModelSelector>,
34) -> AcpModelSelector {
35 let delegate =
36 AcpModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx);
37 Picker::list(delegate, window, cx)
38 .show_scrollbar(true)
39 .width(rems(20.))
40 .max_height(Some(rems(20.).into()))
41}
42
43enum AcpModelPickerEntry {
44 Separator(SharedString),
45 Model(AgentModelInfo, bool),
46}
47
48pub struct AcpModelPickerDelegate {
49 selector: Rc<dyn AgentModelSelector>,
50 agent_server: Rc<dyn AgentServer>,
51 fs: Arc<dyn Fs>,
52 filtered_entries: Vec<AcpModelPickerEntry>,
53 models: Option<AgentModelList>,
54 selected_index: usize,
55 selected_description: Option<(usize, SharedString, bool)>,
56 selected_model: Option<AgentModelInfo>,
57 favorites: HashSet<ModelId>,
58 _refresh_models_task: Task<()>,
59 _settings_subscription: Subscription,
60 focus_handle: FocusHandle,
61}
62
63impl AcpModelPickerDelegate {
64 fn new(
65 selector: Rc<dyn AgentModelSelector>,
66 agent_server: Rc<dyn AgentServer>,
67 fs: Arc<dyn Fs>,
68 focus_handle: FocusHandle,
69 window: &mut Window,
70 cx: &mut Context<AcpModelSelector>,
71 ) -> Self {
72 let rx = selector.watch(cx);
73 let refresh_models_task = {
74 cx.spawn_in(window, {
75 async move |this, cx| {
76 async fn refresh(
77 this: &WeakEntity<Picker<AcpModelPickerDelegate>>,
78 cx: &mut AsyncWindowContext,
79 ) -> Result<()> {
80 let (models_task, selected_model_task) = this.update(cx, |this, cx| {
81 (
82 this.delegate.selector.list_models(cx),
83 this.delegate.selector.selected_model(cx),
84 )
85 })?;
86
87 let (models, selected_model) =
88 futures::join!(models_task, selected_model_task);
89
90 this.update_in(cx, |this, window, cx| {
91 this.delegate.models = models.ok();
92 this.delegate.selected_model = selected_model.ok();
93 this.refresh(window, cx)
94 })
95 }
96
97 refresh(&this, cx).await.log_err();
98 if let Some(mut rx) = rx {
99 while let Ok(()) = rx.recv().await {
100 refresh(&this, cx).await.log_err();
101 }
102 }
103 }
104 })
105 };
106
107 let agent_server_for_subscription = agent_server.clone();
108 let settings_subscription =
109 cx.observe_global_in::<SettingsStore>(window, move |picker, window, cx| {
110 // Only refresh if the favorites actually changed to avoid redundant work
111 // when other settings are modified (e.g., user editing settings.json)
112 let new_favorites = agent_server_for_subscription.favorite_model_ids(cx);
113 if new_favorites != picker.delegate.favorites {
114 picker.delegate.favorites = new_favorites;
115 picker.refresh(window, cx);
116 }
117 });
118 let favorites = agent_server.favorite_model_ids(cx);
119
120 Self {
121 selector,
122 agent_server,
123 fs,
124 filtered_entries: Vec::new(),
125 models: None,
126 selected_model: None,
127 selected_index: 0,
128 selected_description: None,
129 favorites,
130 _refresh_models_task: refresh_models_task,
131 _settings_subscription: settings_subscription,
132 focus_handle,
133 }
134 }
135
136 pub fn active_model(&self) -> Option<&AgentModelInfo> {
137 self.selected_model.as_ref()
138 }
139
140 pub fn favorites_count(&self) -> usize {
141 self.favorites.len()
142 }
143
144 pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
145 if self.favorites.is_empty() {
146 return;
147 }
148
149 let Some(models) = &self.models else {
150 return;
151 };
152
153 let all_models: Vec<&AgentModelInfo> = match models {
154 AgentModelList::Flat(list) => list.iter().collect(),
155 AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
156 };
157
158 let favorite_models: Vec<_> = all_models
159 .into_iter()
160 .filter(|model| self.favorites.contains(&model.id))
161 .unique_by(|model| &model.id)
162 .collect();
163
164 if favorite_models.is_empty() {
165 return;
166 }
167
168 let current_id = self.selected_model.as_ref().map(|m| &m.id);
169
170 let current_index_in_favorites = current_id
171 .and_then(|id| favorite_models.iter().position(|m| &m.id == id))
172 .unwrap_or(usize::MAX);
173
174 let next_index = if current_index_in_favorites == usize::MAX {
175 0
176 } else {
177 (current_index_in_favorites + 1) % favorite_models.len()
178 };
179
180 let next_model = favorite_models[next_index].clone();
181
182 self.selector
183 .select_model(next_model.id.clone(), cx)
184 .detach_and_log_err(cx);
185
186 self.selected_model = Some(next_model);
187
188 // Keep the picker selection aligned with the newly-selected model
189 if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
190 matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
191 }) {
192 self.set_selected_index(new_index, window, cx);
193 } else {
194 cx.notify();
195 }
196 }
197}
198
199impl PickerDelegate for AcpModelPickerDelegate {
200 type ListItem = AnyElement;
201
202 fn match_count(&self) -> usize {
203 self.filtered_entries.len()
204 }
205
206 fn selected_index(&self) -> usize {
207 self.selected_index
208 }
209
210 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
211 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
212 cx.notify();
213 }
214
215 fn can_select(
216 &mut self,
217 ix: usize,
218 _window: &mut Window,
219 _cx: &mut Context<Picker<Self>>,
220 ) -> bool {
221 match self.filtered_entries.get(ix) {
222 Some(AcpModelPickerEntry::Model(_, _)) => true,
223 Some(AcpModelPickerEntry::Separator(_)) | None => false,
224 }
225 }
226
227 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
228 "Select a model…".into()
229 }
230
231 fn update_matches(
232 &mut self,
233 query: String,
234 window: &mut Window,
235 cx: &mut Context<Picker<Self>>,
236 ) -> Task<()> {
237 let favorites = self.favorites.clone();
238
239 cx.spawn_in(window, async move |this, cx| {
240 let filtered_models = match this
241 .read_with(cx, |this, cx| {
242 this.delegate.models.clone().map(move |models| {
243 fuzzy_search(models, query, cx.background_executor().clone())
244 })
245 })
246 .ok()
247 .flatten()
248 {
249 Some(task) => task.await,
250 None => AgentModelList::Flat(vec![]),
251 };
252
253 this.update_in(cx, |this, window, cx| {
254 this.delegate.filtered_entries =
255 info_list_to_picker_entries(filtered_models, &favorites);
256 // Finds the currently selected model in the list
257 let new_index = this
258 .delegate
259 .selected_model
260 .as_ref()
261 .and_then(|selected| {
262 this.delegate.filtered_entries.iter().position(|entry| {
263 if let AcpModelPickerEntry::Model(model_info, _) = entry {
264 model_info.id == selected.id
265 } else {
266 false
267 }
268 })
269 })
270 .unwrap_or(0);
271 this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
272 cx.notify();
273 })
274 .ok();
275 })
276 }
277
278 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
279 if let Some(AcpModelPickerEntry::Model(model_info, _)) =
280 self.filtered_entries.get(self.selected_index)
281 {
282 if window.modifiers().secondary() {
283 let default_model = self.agent_server.default_model(cx);
284 let is_default = default_model.as_ref() == Some(&model_info.id);
285
286 self.agent_server.set_default_model(
287 if is_default {
288 None
289 } else {
290 Some(model_info.id.clone())
291 },
292 self.fs.clone(),
293 cx,
294 );
295 }
296
297 self.selector
298 .select_model(model_info.id.clone(), cx)
299 .detach_and_log_err(cx);
300 self.selected_model = Some(model_info.clone());
301 let current_index = self.selected_index;
302 self.set_selected_index(current_index, window, cx);
303
304 cx.emit(DismissEvent);
305 }
306 }
307
308 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
309 cx.defer_in(window, |picker, window, cx| {
310 picker.set_query("", window, cx);
311 });
312 }
313
314 fn render_match(
315 &self,
316 ix: usize,
317 selected: bool,
318 _: &mut Window,
319 cx: &mut Context<Picker<Self>>,
320 ) -> Option<Self::ListItem> {
321 match self.filtered_entries.get(ix)? {
322 AcpModelPickerEntry::Separator(title) => {
323 Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
324 }
325 AcpModelPickerEntry::Model(model_info, is_favorite) => {
326 let is_selected = Some(model_info) == self.selected_model.as_ref();
327 let default_model = self.agent_server.default_model(cx);
328 let is_default = default_model.as_ref() == Some(&model_info.id);
329
330 let is_favorite = *is_favorite;
331 let handle_action_click = {
332 let model_id = model_info.id.clone();
333 let fs = self.fs.clone();
334 let agent_server = self.agent_server.clone();
335
336 cx.listener(move |_, _, _, cx| {
337 agent_server.toggle_favorite_model(
338 model_id.clone(),
339 !is_favorite,
340 fs.clone(),
341 cx,
342 );
343 })
344 };
345
346 Some(
347 div()
348 .id(("model-picker-menu-child", ix))
349 .when_some(model_info.description.clone(), |this, description| {
350 this.on_hover(cx.listener(move |menu, hovered, _, cx| {
351 if *hovered {
352 menu.delegate.selected_description =
353 Some((ix, description.clone(), is_default));
354 } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) {
355 menu.delegate.selected_description = None;
356 }
357 cx.notify();
358 }))
359 })
360 .child(
361 ModelSelectorListItem::new(ix, model_info.name.clone())
362 .map(|this| match &model_info.icon {
363 Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()),
364 Some(AgentModelIcon::Named(icon)) => this.icon(*icon),
365 None => this,
366 })
367 .is_selected(is_selected)
368 .is_focused(selected)
369 .is_favorite(is_favorite)
370 .on_toggle_favorite(handle_action_click),
371 )
372 .into_any_element(),
373 )
374 }
375 }
376 }
377
378 fn documentation_aside(
379 &self,
380 _window: &mut Window,
381 _cx: &mut Context<Picker<Self>>,
382 ) -> Option<ui::DocumentationAside> {
383 self.selected_description
384 .as_ref()
385 .map(|(_, description, is_default)| {
386 let description = description.clone();
387 let is_default = *is_default;
388
389 DocumentationAside::new(
390 DocumentationSide::Left,
391 Rc::new(move |_| {
392 v_flex()
393 .gap_1()
394 .child(Label::new(description.clone()))
395 .child(HoldForDefault::new(is_default))
396 .into_any_element()
397 }),
398 )
399 })
400 }
401
402 fn documentation_aside_index(&self) -> Option<usize> {
403 self.selected_description.as_ref().map(|(ix, _, _)| *ix)
404 }
405
406 fn render_footer(
407 &self,
408 _window: &mut Window,
409 _cx: &mut Context<Picker<Self>>,
410 ) -> Option<AnyElement> {
411 let focus_handle = self.focus_handle.clone();
412
413 if !self.selector.should_render_footer() {
414 return None;
415 }
416
417 Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
418 }
419}
420
421fn info_list_to_picker_entries(
422 model_list: AgentModelList,
423 favorites: &HashSet<ModelId>,
424) -> Vec<AcpModelPickerEntry> {
425 let mut entries = Vec::new();
426
427 let all_models: Vec<_> = match &model_list {
428 AgentModelList::Flat(list) => list.iter().collect(),
429 AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(),
430 };
431
432 let favorite_models: Vec<_> = all_models
433 .iter()
434 .filter(|m| favorites.contains(&m.id))
435 .unique_by(|m| &m.id)
436 .collect();
437
438 let has_favorites = !favorite_models.is_empty();
439 if has_favorites {
440 entries.push(AcpModelPickerEntry::Separator("Favorite".into()));
441 for model in favorite_models {
442 entries.push(AcpModelPickerEntry::Model((*model).clone(), true));
443 }
444 }
445
446 match model_list {
447 AgentModelList::Flat(list) => {
448 if has_favorites {
449 entries.push(AcpModelPickerEntry::Separator("All".into()));
450 }
451 for model in list {
452 let is_favorite = favorites.contains(&model.id);
453 entries.push(AcpModelPickerEntry::Model(model, is_favorite));
454 }
455 }
456 AgentModelList::Grouped(index_map) => {
457 for (group_name, models) in index_map {
458 entries.push(AcpModelPickerEntry::Separator(group_name.0));
459 for model in models {
460 let is_favorite = favorites.contains(&model.id);
461 entries.push(AcpModelPickerEntry::Model(model, is_favorite));
462 }
463 }
464 }
465 }
466
467 entries
468}
469
470async fn fuzzy_search(
471 model_list: AgentModelList,
472 query: String,
473 executor: BackgroundExecutor,
474) -> AgentModelList {
475 async fn fuzzy_search_list(
476 model_list: Vec<AgentModelInfo>,
477 query: &str,
478 executor: BackgroundExecutor,
479 ) -> Vec<AgentModelInfo> {
480 let candidates = model_list
481 .iter()
482 .enumerate()
483 .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref()))
484 .collect::<Vec<_>>();
485 let mut matches = match_strings(
486 &candidates,
487 query,
488 false,
489 true,
490 100,
491 &Default::default(),
492 executor,
493 )
494 .await;
495
496 matches.sort_unstable_by_key(|mat| {
497 let candidate = &candidates[mat.candidate_id];
498 (Reverse(OrderedFloat(mat.score)), candidate.id)
499 });
500
501 matches
502 .into_iter()
503 .map(|mat| model_list[mat.candidate_id].clone())
504 .collect()
505 }
506
507 match model_list {
508 AgentModelList::Flat(model_list) => {
509 AgentModelList::Flat(fuzzy_search_list(model_list, &query, executor).await)
510 }
511 AgentModelList::Grouped(index_map) => {
512 let groups =
513 futures::future::join_all(index_map.into_iter().map(|(group_name, models)| {
514 fuzzy_search_list(models, &query, executor.clone())
515 .map(|results| (group_name, results))
516 }))
517 .await;
518 AgentModelList::Grouped(IndexMap::from_iter(
519 groups
520 .into_iter()
521 .filter(|(_, results)| !results.is_empty()),
522 ))
523 }
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use agent_client_protocol as acp;
530 use gpui::TestAppContext;
531
532 use super::*;
533
534 fn create_model_list(grouped_models: Vec<(&str, Vec<&str>)>) -> AgentModelList {
535 AgentModelList::Grouped(IndexMap::from_iter(grouped_models.into_iter().map(
536 |(group, models)| {
537 (
538 acp_thread::AgentModelGroupName(group.to_string().into()),
539 models
540 .into_iter()
541 .map(|model| acp_thread::AgentModelInfo {
542 id: acp::ModelId::new(model.to_string()),
543 name: model.to_string().into(),
544 description: None,
545 icon: None,
546 })
547 .collect::<Vec<_>>(),
548 )
549 },
550 )))
551 }
552
553 fn assert_models_eq(result: AgentModelList, expected: Vec<(&str, Vec<&str>)>) {
554 let AgentModelList::Grouped(groups) = result else {
555 panic!("Expected LanguageModelInfoList::Grouped, got {:?}", result);
556 };
557
558 assert_eq!(
559 groups.len(),
560 expected.len(),
561 "Number of groups doesn't match"
562 );
563
564 for (i, (expected_group, expected_models)) in expected.iter().enumerate() {
565 let (actual_group, actual_models) = groups.get_index(i).unwrap();
566 assert_eq!(
567 actual_group.0.as_ref(),
568 *expected_group,
569 "Group at position {} doesn't match expected group",
570 i
571 );
572 assert_eq!(
573 actual_models.len(),
574 expected_models.len(),
575 "Number of models in group {} doesn't match",
576 expected_group
577 );
578
579 for (j, expected_model_name) in expected_models.iter().enumerate() {
580 assert_eq!(
581 actual_models[j].name, *expected_model_name,
582 "Model at position {} in group {} doesn't match expected model",
583 j, expected_group
584 );
585 }
586 }
587 }
588
589 fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
590 models
591 .into_iter()
592 .map(|m| ModelId::new(m.to_string()))
593 .collect()
594 }
595
596 fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
597 entries
598 .iter()
599 .filter_map(|entry| match entry {
600 AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()),
601 _ => None,
602 })
603 .collect()
604 }
605
606 fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
607 entries
608 .iter()
609 .map(|entry| match entry {
610 AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(),
611 AcpModelPickerEntry::Separator(s) => &s,
612 })
613 .collect()
614 }
615
616 #[gpui::test]
617 async fn test_fuzzy_match(cx: &mut TestAppContext) {
618 let models = create_model_list(vec![
619 (
620 "zed",
621 vec![
622 "Claude 3.7 Sonnet",
623 "Claude 3.7 Sonnet Thinking",
624 "gpt-4.1",
625 "gpt-4.1-nano",
626 ],
627 ),
628 ("openai", vec!["gpt-3.5-turbo", "gpt-4.1", "gpt-4.1-nano"]),
629 ("ollama", vec!["mistral", "deepseek"]),
630 ]);
631
632 // Results should preserve models order whenever possible.
633 // In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
634 // similarity scores, but `zed/gpt-4.1` was higher in the models list,
635 // so it should appear first in the results.
636 let results = fuzzy_search(models.clone(), "41".into(), cx.executor()).await;
637 assert_models_eq(
638 results,
639 vec![
640 ("zed", vec!["gpt-4.1", "gpt-4.1-nano"]),
641 ("openai", vec!["gpt-4.1", "gpt-4.1-nano"]),
642 ],
643 );
644
645 // Fuzzy search
646 let results = fuzzy_search(models.clone(), "4n".into(), cx.executor()).await;
647 assert_models_eq(
648 results,
649 vec![
650 ("zed", vec!["gpt-4.1-nano"]),
651 ("openai", vec!["gpt-4.1-nano"]),
652 ],
653 );
654 }
655
656 #[gpui::test]
657 fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) {
658 let models = create_model_list(vec![
659 ("zed", vec!["zed/claude", "zed/gemini"]),
660 ("openai", vec!["openai/gpt-5"]),
661 ]);
662 let favorites = create_favorites(vec!["zed/gemini"]);
663
664 let entries = info_list_to_picker_entries(models, &favorites);
665
666 assert!(matches!(
667 entries.first(),
668 Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
669 ));
670
671 let model_ids = get_entry_model_ids(&entries);
672 assert_eq!(model_ids[0], "zed/gemini");
673 }
674
675 #[gpui::test]
676 fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) {
677 let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
678 let favorites = create_favorites(vec![]);
679
680 let entries = info_list_to_picker_entries(models, &favorites);
681
682 assert!(matches!(
683 entries.first(),
684 Some(AcpModelPickerEntry::Separator(s)) if s == "zed"
685 ));
686 }
687
688 #[gpui::test]
689 fn test_models_have_correct_actions(_cx: &mut TestAppContext) {
690 let models = create_model_list(vec![
691 ("zed", vec!["zed/claude", "zed/gemini"]),
692 ("openai", vec!["openai/gpt-5"]),
693 ]);
694 let favorites = create_favorites(vec!["zed/claude"]);
695
696 let entries = info_list_to_picker_entries(models, &favorites);
697
698 for entry in &entries {
699 if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
700 if info.id.0.as_ref() == "zed/claude" {
701 assert!(is_favorite, "zed/claude should be a favorite");
702 } else {
703 assert!(!is_favorite, "{} should not be a favorite", info.id.0);
704 }
705 }
706 }
707 }
708
709 #[gpui::test]
710 fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) {
711 let models = create_model_list(vec![
712 ("zed", vec!["zed/claude", "zed/gemini"]),
713 ("openai", vec!["openai/gpt-5", "openai/gpt-4"]),
714 ]);
715 let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
716
717 let entries = info_list_to_picker_entries(models, &favorites);
718 let model_ids = get_entry_model_ids(&entries);
719
720 assert_eq!(model_ids[0], "zed/gemini");
721 assert_eq!(model_ids[1], "openai/gpt-5");
722
723 assert!(model_ids[2..].contains(&"zed/gemini"));
724 assert!(model_ids[2..].contains(&"openai/gpt-5"));
725 }
726
727 #[gpui::test]
728 fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) {
729 let models = create_model_list(vec![
730 ("Recommended", vec!["zed/claude", "anthropic/claude"]),
731 ("Zed", vec!["zed/claude", "zed/gpt-5"]),
732 ("Antropic", vec!["anthropic/claude"]),
733 ("OpenAI", vec!["openai/gpt-5"]),
734 ]);
735
736 let favorites = create_favorites(vec!["zed/claude"]);
737
738 let entries = info_list_to_picker_entries(models, &favorites);
739 let labels = get_entry_labels(&entries);
740
741 assert_eq!(
742 labels,
743 vec![
744 "Favorite",
745 "zed/claude",
746 "Recommended",
747 "zed/claude",
748 "anthropic/claude",
749 "Zed",
750 "zed/claude",
751 "zed/gpt-5",
752 "Antropic",
753 "anthropic/claude",
754 "OpenAI",
755 "openai/gpt-5"
756 ]
757 );
758 }
759
760 #[gpui::test]
761 fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) {
762 let models = AgentModelList::Flat(vec![
763 acp_thread::AgentModelInfo {
764 id: acp::ModelId::new("zed/claude".to_string()),
765 name: "Claude".into(),
766 description: None,
767 icon: None,
768 },
769 acp_thread::AgentModelInfo {
770 id: acp::ModelId::new("zed/gemini".to_string()),
771 name: "Gemini".into(),
772 description: None,
773 icon: None,
774 },
775 ]);
776 let favorites = create_favorites(vec!["zed/gemini"]);
777
778 let entries = info_list_to_picker_entries(models, &favorites);
779
780 assert!(matches!(
781 entries.first(),
782 Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite"
783 ));
784
785 assert!(entries.iter().any(|e| matches!(
786 e,
787 AcpModelPickerEntry::Separator(s) if s == "All"
788 )));
789 }
790
791 #[gpui::test]
792 fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) {
793 let empty_favorites: HashSet<ModelId> = HashSet::default();
794 assert_eq!(empty_favorites.len(), 0);
795
796 let one_favorite = create_favorites(vec!["model-a"]);
797 assert_eq!(one_favorite.len(), 1);
798
799 let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]);
800 assert_eq!(multiple_favorites.len(), 3);
801
802 let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]);
803 assert_eq!(with_duplicates.len(), 2);
804 }
805
806 #[gpui::test]
807 fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) {
808 let models = AgentModelList::Flat(vec![
809 acp_thread::AgentModelInfo {
810 id: acp::ModelId::new("favorite-model".to_string()),
811 name: "Favorite".into(),
812 description: None,
813 icon: None,
814 },
815 acp_thread::AgentModelInfo {
816 id: acp::ModelId::new("regular-model".to_string()),
817 name: "Regular".into(),
818 description: None,
819 icon: None,
820 },
821 ]);
822 let favorites = create_favorites(vec!["favorite-model"]);
823
824 let entries = info_list_to_picker_entries(models, &favorites);
825
826 for entry in &entries {
827 if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
828 if info.id.0.as_ref() == "favorite-model" {
829 assert!(*is_favorite, "favorite-model should have is_favorite=true");
830 } else if info.id.0.as_ref() == "regular-model" {
831 assert!(!*is_favorite, "regular-model should have is_favorite=false");
832 }
833 }
834 }
835 }
836}