1use std::cell::RefCell;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use gpui::{Entity, SharedString};
6
7use crate::prelude::*;
8use crate::score;
9use crate::{ContextMenu, DropdownMenu, DropdownStyle, TextInput};
10
11const FUZZY_FILTER_THRESHOLD: usize = 8;
15
16type OnSelectModel = Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>;
17
18#[derive(IntoElement, RegisterComponent)]
24pub struct AgentModelSelector {
25 id: ElementId,
26 current_model_id: Option<SharedString>,
27 available_model_ids: Vec<SharedString>,
28 on_select: Option<OnSelectModel>,
29}
30
31impl AgentModelSelector {
32 pub fn new(
33 id: impl Into<ElementId>,
34 current_model_id: Option<&str>,
35 available_model_ids: &[String],
36 ) -> Self {
37 Self {
38 id: id.into(),
39 current_model_id: current_model_id.map(SharedString::from),
40 available_model_ids: available_model_ids
41 .iter()
42 .map(|id| SharedString::from(id.clone()))
43 .collect(),
44 on_select: None,
45 }
46 }
47
48 pub fn on_select(
49 mut self,
50 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
51 ) -> Self {
52 self.on_select = Some(Arc::new(handler));
53 self
54 }
55}
56
57impl RenderOnce for AgentModelSelector {
58 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
59 let label = self
60 .current_model_id
61 .clone()
62 .unwrap_or_else(|| "Select model".into());
63 let current = self.current_model_id.clone();
64 let on_select = self.on_select.clone();
65 let available_model_ids = self.available_model_ids.clone();
66
67 let menu = if available_model_ids.len() > FUZZY_FILTER_THRESHOLD {
68 build_filterable_menu(window, cx, current, available_model_ids, on_select)
69 } else {
70 build_flat_menu(window, cx, current, available_model_ids, on_select)
71 };
72
73 DropdownMenu::new(self.id, label, menu).style(DropdownStyle::Subtle)
74 }
75}
76
77fn build_flat_menu(
78 window: &mut Window,
79 cx: &mut App,
80 current: Option<SharedString>,
81 available_model_ids: Vec<SharedString>,
82 on_select: Option<OnSelectModel>,
83) -> Entity<ContextMenu> {
84 ContextMenu::build(window, cx, move |mut menu, _window, _cx| {
85 for model_id in &available_model_ids {
86 menu = push_model_entry(menu, model_id.clone(), current.as_ref(), on_select.clone());
87 }
88 menu
89 })
90}
91
92fn build_filterable_menu(
98 window: &mut Window,
99 cx: &mut App,
100 current: Option<SharedString>,
101 available_model_ids: Vec<SharedString>,
102 on_select: Option<OnSelectModel>,
103) -> Entity<ContextMenu> {
104 let query_input: Rc<RefCell<Option<Entity<TextInput>>>> = Rc::default();
105
106 ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| {
107 let input = query_input
108 .borrow_mut()
109 .get_or_insert_with(|| {
110 let input = cx.new(|cx| TextInput::new(cx).placeholder("Filter models…"));
111 cx.observe_in(&input, window, |menu, _, window, cx| {
112 menu.rebuild(window, cx);
113 })
114 .detach();
115 input
116 })
117 .clone();
118
119 let query = input.read(cx).text().to_string();
120 let mut matched: Vec<(SharedString, i32)> = available_model_ids
121 .iter()
122 .filter_map(|id| score(&query, id.as_ref()).map(|s| (id.clone(), s)))
123 .collect();
124 matched.sort_by(|a, b| b.1.cmp(&a.1));
125
126 menu = menu.custom_row(move |_window, _cx| {
127 div()
128 .w_full()
129 .px_1()
130 .child(input.clone())
131 .into_any_element()
132 });
133
134 if matched.is_empty() {
135 menu = menu.header("No matching models");
136 }
137
138 for (model_id, _score) in matched {
139 menu = push_model_entry(menu, model_id, current.as_ref(), on_select.clone());
140 }
141
142 menu.keep_open_on_confirm(false)
143 })
144}
145
146fn push_model_entry(
147 menu: ContextMenu,
148 model_id: SharedString,
149 current: Option<&SharedString>,
150 on_select: Option<OnSelectModel>,
151) -> ContextMenu {
152 let is_current = current == Some(&model_id);
153 let entry_id = model_id.clone();
154 menu.toggleable_entry(
155 model_id,
156 is_current,
157 IconPosition::End,
158 None,
159 move |window, cx| {
160 if let Some(on_select) = &on_select {
161 on_select(entry_id.clone(), window, cx);
162 }
163 },
164 )
165}
166
167impl Component for AgentModelSelector {
168 fn scope() -> ComponentScope {
169 ComponentScope::Agent
170 }
171
172 fn name() -> &'static str {
173 "AgentModelSelector"
174 }
175
176 fn description() -> Option<&'static str> {
177 Some(
178 "A dropdown for picking the active model for an agent thread. \
179 The caller supplies the current model id and the full list of \
180 available model ids; no data fetching happens here. Lists \
181 longer than a handful of entries gain a fuzzy-filter input.",
182 )
183 }
184
185 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
186 let models = vec![
187 "claude-opus-4.6".to_string(),
188 "claude-sonnet-4.6".to_string(),
189 "gpt-5".to_string(),
190 ];
191
192 let many_models: Vec<String> = [
193 "claude-opus-4.6",
194 "claude-sonnet-4.6",
195 "claude-haiku-4.6",
196 "gpt-5",
197 "gpt-5-mini",
198 "gpt-5-nano",
199 "gemini-2.5-pro",
200 "gemini-2.5-flash",
201 "llama-4-scout",
202 "llama-4-maverick",
203 ]
204 .into_iter()
205 .map(String::from)
206 .collect();
207
208 Some(
209 v_flex()
210 .gap_4()
211 .child(single_example(
212 "With selection",
213 AgentModelSelector::new("model-selector-1", Some("claude-sonnet-4.6"), &models)
214 .into_any_element(),
215 ))
216 .child(single_example(
217 "No selection",
218 AgentModelSelector::new("model-selector-2", None, &models).into_any_element(),
219 ))
220 .child(single_example(
221 "Long list (fuzzy-filterable)",
222 AgentModelSelector::new("model-selector-3", Some("gpt-5"), &many_models)
223 .into_any_element(),
224 ))
225 .into_any_element(),
226 )
227 }
228}