Skip to main content

ui/components/ai/
agent_model_selector.rs

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
11/// Model lists at or under this length render as a flat menu. Longer lists
12/// gain a fuzzy-filter input above the entries so a specific model can be
13/// typed to find rather than scrolled to.
14const FUZZY_FILTER_THRESHOLD: usize = 8;
15
16type OnSelectModel = Arc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>;
17
18/// A dropdown for picking the active model for an agent thread.
19///
20/// This is a pure builder: the caller supplies the current selection and the
21/// full list of selectable model ids (no fetching happens here), and is
22/// notified via `on_select` when the user picks a different one.
23#[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
92/// Builds a menu with a live fuzzy-filter input above the model entries. The
93/// filter text lives in a [`TextInput`] entity created once (on first build)
94/// and reused across rebuilds, so typing doesn't lose focus or content; each
95/// keystroke rebuilds the menu (`ContextMenu::build_persistent`) with the
96/// list re-scored via [`score`].
97fn 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}