diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index ff7e800f6a..989d662104 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -408,6 +408,34 @@ fn mark_ambiguous(rows: &mut [ModelRowData]) { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderHeader { + harness: HarnessId, + id: String, + name: SharedString, + count: usize, + collapsed: bool, +} + +#[derive(Debug, Clone)] +enum ModelListRow { + Provider(ProviderHeader), + Model { + row: ModelRowData, + ordinal: usize, + under_header: bool, + }, +} + +impl ModelListRow { + fn model_row(&self) -> Option<&ModelRowData> { + match self { + ModelListRow::Model { row, .. } => Some(row), + ModelListRow::Provider(_) => None, + } + } +} + /// Which picker popover is open. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PickerKind { @@ -563,7 +591,7 @@ pub struct Pickers { /// Flattened rows the list/keyboard/⌘N all walk, cached per /// [`ModelRowsKey`]: a 7k-model catalog rebuilt+ranked on every /// keystroke, arrow press AND render was the picker's open/scroll lag. - model_rows_cache: std::cell::RefCell>)>>, + model_rows_cache: std::cell::RefCell>)>>, /// Bumped on every catalog/favorites mutation; invalidates the cache. catalog_rev: u64, /// Hover/drag state of the floating menu scrollbar. One instance serves @@ -1108,7 +1136,7 @@ impl Pickers { if self.open_kind() != Some(PickerKind::HarnessModel) { return false; } - self.activate_model_index(slot, cx); + self.activate_model_slot(slot, cx); cx.notify(); true } @@ -1883,7 +1911,7 @@ impl Pickers { /// restricts every view to its own harness. /// Cached [`Self::visible_model_rows`]: selection/highlight changes and /// re-renders share one flattened list until an input actually changes. - fn model_rows(&self, cx: &App) -> std::sync::Arc> { + fn model_rows(&self, cx: &App) -> std::sync::Arc> { let key = ModelRowsKey { query: self.search.read(cx).text().trim().to_string(), rail: self.model_rail, @@ -1902,7 +1930,7 @@ impl Pickers { rows } - fn visible_model_rows(&self, cx: &App) -> Vec { + fn visible_model_rows(&self, cx: &App) -> Vec { let effective = self.effective_harness(cx); let descriptors = self.rail_descriptors(cx); // Favorite lookups are per-row; the Vec scan made the flatten @@ -1968,7 +1996,20 @@ impl Pickers { } } mark_ambiguous(&mut rows); - rows + let grouped = query.is_empty() + && self.model_rail == ModelRail::Harness + && effective == Some(HarnessId::Opencode); + if !grouped { + return flat_model_rows(rows); + } + group_by_provider( + rows, + |row| row.selected_only || favorites.contains(&(row.harness, row.model.id.as_str())), + |provider| { + self.defaults + .is_provider_collapsed(HarnessId::Opencode, provider) + }, + ) } /// The row the keyboard-nav highlight starts on: the resolved selected @@ -1982,7 +2023,9 @@ impl Pickers { self.model_rows(cx) .iter() .position(|row| { - Some(row.harness) == effective && selected == Some(row.model.id.as_str()) + matches!(row, ModelListRow::Model { row, .. } + if Some(row.harness) == effective + && selected == Some(row.model.id.as_str())) }) .unwrap_or(0) } @@ -2005,12 +2048,27 @@ impl Pickers { } } + fn activate_model_slot(&mut self, slot: usize, cx: &mut Context) { + let ix = self + .model_rows(cx) + .iter() + .position(|row| matches!(row, ModelListRow::Model { ordinal, .. } if *ordinal == slot)); + if let Some(ix) = ix { + self.activate_model_index(ix, cx); + } + } + /// Pick the visible row at `ix` — a foreign-harness row (favorites / /// search) switches the harness first, exactly like clicking its rail /// icon and then the model. fn activate_model_index(&mut self, ix: usize, cx: &mut Context) { - let Some(row) = self.model_rows(cx).get(ix).cloned() else { - return; + let row = match self.model_rows(cx).get(ix).cloned() { + Some(ModelListRow::Model { row, .. }) => row, + Some(ModelListRow::Provider(header)) => { + self.toggle_provider_collapsed(header.harness, &header.id, cx); + return; + } + None => return, }; if row.selected_only { return; @@ -2024,6 +2082,19 @@ impl Pickers { self.pick_model(row.model.id, cx); } + fn toggle_provider_collapsed( + &mut self, + harness: HarnessId, + provider: &str, + cx: &mut Context, + ) { + self.reload_defaults(); + self.defaults.toggle_provider_collapsed(harness, provider); + self.persist_defaults(); + self.catalog_rev += 1; + cx.notify(); + } + /// Star/unstar a model and persist it with the sticky defaults. fn toggle_model_favorite(&mut self, harness: HarnessId, model: &str, cx: &mut Context) { self.reload_defaults(); @@ -2556,7 +2627,7 @@ impl Pickers { && let Ok(n) = event.keystroke.key.parse::() && (1..=9).contains(&n) { - self.activate_model_index(n - 1, cx); + self.activate_model_slot(n - 1, cx); cx.notify(); return; } @@ -3788,9 +3859,22 @@ impl Pickers { entity.update(app, |this, cx| { range .filter_map(|ix| { - row_data - .get(ix) - .map(|row| this.render_model_row(ix, row, cx)) + row_data.get(ix).map(|row| match row { + ModelListRow::Provider(header) => { + this.render_provider_header(ix, header, cx) + } + ModelListRow::Model { + row, + ordinal, + under_header, + } => this.render_model_row( + ix, + *ordinal, + *under_header, + row, + cx, + ), + }) }) .collect::>() }) @@ -3914,6 +3998,74 @@ impl Pickers { .into_any_element() } + fn render_provider_header( + &mut self, + ix: usize, + header: &ProviderHeader, + cx: &mut Context, + ) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let mut el = div() + .id(("model-provider", ix)) + .px(px(8.0)) + .py(px(5.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .flex_row() + .items_center() + .gap(px(10.0)) + .cursor_pointer(); + if ix == self.active { + el = el.bg(crate::theme::ink(0.05)); + } + let el = el + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered && this.active != ix { + this.active = ix; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, _, cx| { + this.activate_model_index(ix, cx); + })) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(11.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted) + .child(header.name.clone()), + ) + .child( + div() + .flex_none() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_faint) + .child(SharedString::from(header.count.to_string())), + ) + .child( + div() + .flex_none() + .w(px(22.0)) + .h(px(22.0)) + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(if header.collapsed { + crate::icons::ALT_ARROW_RIGHT + } else { + crate::icons::ALT_ARROW_DOWN + }) + .size(px(13.0)) + .text_color(theme.text_muted), + ), + ); + div().pb(px(2.0)).child(el).into_any_element() + } + /// One model row for the virtualized list. `ix` is the row's GLOBAL index /// (⌘N chips, hover-cursor, and activation all key on it). The 2px /// inter-row gap is baked into each item's bottom padding so every item @@ -3921,6 +4073,8 @@ impl Pickers { fn render_model_row( &mut self, ix: usize, + ordinal: usize, + under_header: bool, row: &ModelRowData, cx: &mut Context, ) -> AnyElement { @@ -3946,6 +4100,7 @@ impl Pickers { .model .description .as_deref() + .filter(|_| !under_header) .map(str::trim) .filter(|d| { row.ambiguous && !d.is_empty() && !d.eq_ignore_ascii_case(harness_name.as_ref()) @@ -4076,8 +4231,8 @@ impl Pickers { this.activate_model_index(ix, cx); })) .child(body); - if ix < 9 { - el = el.child(popover::kbd_hint(&theme, &format!("⌘{}", ix + 1))); + if ordinal < 9 { + el = el.child(popover::kbd_hint(&theme, &format!("⌘{}", ordinal + 1))); } // A model configured in place opens its settings card on hover. // The title picker has no settings, so no card either. @@ -4325,7 +4480,7 @@ impl Pickers { /// configured in place. fn card_row(&self, cx: &App) -> Option { self.config_row - .and_then(|ix| self.model_rows(cx).get(ix).cloned()) + .and_then(|ix| self.model_rows(cx).get(ix)?.model_row().cloned()) .filter(|row| { self.title.is_none() && !row.selected_only && configured_in_place(&row.model) }) @@ -5022,6 +5177,88 @@ fn scoped_model_rows<'a>( } } +fn flat_model_rows(rows: Vec) -> Vec { + rows.into_iter() + .enumerate() + .map(|(ordinal, row)| ModelListRow::Model { + row, + ordinal, + under_header: false, + }) + .collect() +} + +fn group_by_provider( + rows: Vec, + is_pinned: impl Fn(&ModelRowData) -> bool, + is_collapsed: impl Fn(&str) -> bool, +) -> Vec { + struct Group { + id: String, + name: SharedString, + rows: Vec, + } + let mut pinned = Vec::new(); + let mut groups: Vec = Vec::new(); + let mut group_at: HashMap = HashMap::new(); + for row in rows { + let provider = row.model.id.split_once('/').map(|(provider, _)| provider); + let Some(provider) = provider.filter(|_| !is_pinned(&row)) else { + pinned.push(row); + continue; + }; + let at = *group_at.entry(provider.to_owned()).or_insert_with(|| { + let name = row + .model + .description + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(provider); + groups.push(Group { + id: provider.to_owned(), + name: SharedString::from(name.to_owned()), + rows: Vec::new(), + }); + groups.len() - 1 + }); + groups[at].rows.push(row); + } + if groups.len() < 2 { + return flat_model_rows( + pinned + .into_iter() + .chain(groups.into_iter().flat_map(|group| group.rows)) + .collect(), + ); + } + let mut out = flat_model_rows(pinned); + let mut ordinal = out.len(); + for group in groups { + let collapsed = is_collapsed(&group.id); + let harness = group.rows[0].harness; + out.push(ModelListRow::Provider(ProviderHeader { + harness, + id: group.id, + name: group.name, + count: group.rows.len(), + collapsed, + })); + if collapsed { + continue; + } + for row in group.rows { + out.push(ModelListRow::Model { + row, + ordinal, + under_header: true, + }); + ordinal += 1; + } + } + out +} + /// Centered muted note filling an empty model list ("No models found"). fn empty_list_note(theme: &Theme, copy: &str) -> AnyElement { div() @@ -5563,8 +5800,9 @@ mod tests { }) ); let rows = pickers.model_rows(cx); - assert_eq!(rows[0].model.id, "saved-model"); - assert!(rows[0].selected_only); + let row = rows[0].model_row().unwrap(); + assert_eq!(row.model.id, "saved-model"); + assert!(row.selected_only); assert_eq!(pickers.selected_model_index(cx), 0); assert_eq!(pickers.models[&HarnessId::Codex].ready().unwrap().len(), 1); pickers.activate_model_index(0, cx); @@ -5574,7 +5812,7 @@ mod tests { pickers.config.model = Some("fresh-default".into()); let rows = pickers.model_rows(cx); assert_eq!(rows.len(), 1); - assert!(!rows[0].selected_only); + assert!(!rows[0].model_row().unwrap().selected_only); assert_eq!( pickers.selected_model_label(cx).as_deref(), Some("Fresh default") @@ -5682,7 +5920,7 @@ mod tests { pickers.model_rail = ModelRail::Favorites; let rows = pickers.model_rows(cx); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, HarnessId::Codex); + assert_eq!(rows[0].model_row().unwrap().harness, HarnessId::Codex); pickers.activate_model_index(0, cx); pickers.pick_reasoning(ReasoningLevel::Low, cx); let create = state.read(cx).unsaved_side_chat_create("side").unwrap(); @@ -6535,8 +6773,10 @@ mod tests { window.focus(&pickers.focus, cx); // Only Fusion is configured in place. let rows = pickers.model_rows(cx); - let configured: Vec<_> = - rows.iter().map(|r| configured_in_place(&r.model)).collect(); + let configured: Vec<_> = rows + .iter() + .map(|r| configured_in_place(&r.model_row().unwrap().model)) + .collect(); assert_eq!(configured, [false, true, false]); pickers.open_config(1, cx); @@ -7325,6 +7565,71 @@ mod tests { assert_eq!(rows[0].model.id, "glm-5.2-b"); } + fn provider_row(id: &str, provider_name: &str) -> ModelRowData { + ModelRowData { + harness: HarnessId::Opencode, + harness_name: "OpenCode".into(), + selected_only: false, + ambiguous: false, + model: Model { + description: Some(provider_name.into()), + ..bare_model(id, id) + }, + } + } + + fn drawn(rows: &[ModelListRow]) -> Vec { + rows.iter() + .map(|row| match row { + ModelListRow::Provider(header) => format!( + "#{} {} ({}){}", + header.id, + header.name, + header.count, + if header.collapsed { " -" } else { "" } + ), + ModelListRow::Model { row, ordinal, .. } => format!("{ordinal} {}", row.model.id), + }) + .collect() + } + + #[test] + fn provider_groups_pin_stars_fold_and_number_models_only() { + let rows = vec![ + provider_row("openai/gpt-mini", "OpenAI"), + provider_row("anthropic/haiku", "Anthropic"), + provider_row("anthropic/opus", "Anthropic"), + provider_row("openai/gpt-5", "OpenAI"), + ]; + let grouped = group_by_provider( + rows, + |row| row.model.id == "openai/gpt-mini", + |provider| provider == "anthropic", + ); + assert_eq!( + drawn(&grouped), + [ + "0 openai/gpt-mini", + "#anthropic Anthropic (2) -", + "#openai OpenAI (1)", + "1 openai/gpt-5", + ] + ); + } + + #[test] + fn a_single_provider_gets_no_header() { + let rows = vec![ + provider_row("opencode/big-pickle", "OpenCode Zen"), + provider_row("opencode/muse", "OpenCode Zen"), + ]; + let grouped = group_by_provider(rows, |_| false, |_| true); + assert_eq!( + drawn(&grouped), + ["0 opencode/big-pickle", "1 opencode/muse"] + ); + } + /// Rows show descriptions only to tell same-named models apart. #[test] fn only_same_named_rows_are_ambiguous() { diff --git a/crates/ui/src/settings/composer.rs b/crates/ui/src/settings/composer.rs index 66e6fd85ec..2e837b3ec1 100644 --- a/crates/ui/src/settings/composer.rs +++ b/crates/ui/src/settings/composer.rs @@ -41,6 +41,13 @@ pub struct FavoriteModel { pub model: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollapsedProvider { + pub harness: HarnessId, + pub provider: String, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ComposerDefaults { @@ -69,6 +76,7 @@ pub struct ComposerDefaults { pub no_project: bool, /// Starred models (the picker's favorites rail), in starring order. pub favorites: Vec, + pub collapsed_providers: Vec, } impl ComposerDefaults { @@ -173,6 +181,29 @@ impl ComposerDefaults { } } + pub fn is_provider_collapsed(&self, harness: HarnessId, provider: &str) -> bool { + self.collapsed_providers + .iter() + .any(|c| c.harness == harness && c.provider == provider) + } + + pub fn toggle_provider_collapsed(&mut self, harness: HarnessId, provider: &str) -> bool { + if let Some(at) = self + .collapsed_providers + .iter() + .position(|c| c.harness == harness && c.provider == provider) + { + self.collapsed_providers.remove(at); + false + } else { + self.collapsed_providers.push(CollapsedProvider { + harness, + provider: provider.to_string(), + }); + true + } + } + /// Merge a loaded catalog into the label cache. Returns whether anything /// changed (callers only save when it did). pub fn remember_labels<'a>( @@ -220,6 +251,28 @@ mod tests { ); } + #[test] + fn provider_folds_survive_a_reload_and_older_files_load_open() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + ComposerDefaults::path(dir.path()), + r#"{"harness":"opencode"}"#, + ) + .unwrap(); + let mut defaults = ComposerDefaults::load(dir.path()); + assert_eq!(defaults.harness, Some(HarnessId::Opencode)); + assert!(!defaults.is_provider_collapsed(HarnessId::Opencode, "anthropic")); + + assert!(defaults.toggle_provider_collapsed(HarnessId::Opencode, "anthropic")); + defaults.save(dir.path()).unwrap(); + let mut loaded = ComposerDefaults::load(dir.path()); + assert!(loaded.is_provider_collapsed(HarnessId::Opencode, "anthropic")); + assert!(!loaded.is_provider_collapsed(HarnessId::Opencode, "openai")); + + assert!(!loaded.toggle_provider_collapsed(HarnessId::Opencode, "anthropic")); + assert!(loaded.collapsed_providers.is_empty()); + } + #[test] fn missing_and_corrupt_files_yield_defaults() { let dir = tempfile::tempdir().unwrap();