//! The composer's pickers, laid out like T3 Code's: a model picker spanning //! every provider (with favorites and search), and menus for reasoning effort //! and access. use gpui_kit::component::{ Icon, IconName, h_flex, input::{Input, InputEvent, InputState}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::claude::{Effort, PermissionMode}; use crate::models::{Catalog, ModelEntry, ProbeStatus, Provider}; use crate::session::{Reasoning, Session}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; /// Claude's own orange, for the provider mark. pub const CLAUDE_ORANGE: u32 = 0xd97757ff; /// A provider's mark, at `size`. pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon { match provider { Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)), Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink), } } /// The floating panel every picker opens in. pub fn panel(p: &Palette) -> Div { v_flex().p(px(6.)).gap(px(1.)).rounded(px(16.)).bg(p.menu_bg).shadow(p.raise.clone()) } pub fn heading(text: &'static str, p: &Palette) -> Div { div().px(px(10.)).pt(px(6.)).pb(px(4.)).engraved(p).child(text.to_uppercase()) } /// One row of a picker menu: an optional icon, a title, an optional /// description and badge, and a check when it's the current choice. #[allow(clippy::too_many_arguments)] pub fn menu_row( id: impl Into, icon: Option, title: impl Into, description: Option, badge: Option<&'static str>, checked: bool, enabled: bool, p: &Palette, on_click: impl Fn(&mut Window, &mut App) + 'static, ) -> Stateful
{ h_flex() .id(id) .items_start() .gap(px(10.)) .px(px(10.)) .py(px(7.)) .rounded(px(10.)) .map(|this| { if enabled { this.cursor_pointer() .hover(|style| style.bg(p.menu_row_hover)) .on_click(move |_, window, cx| on_click(window, cx)) } else { this.opacity(0.45) } }) .when(checked, |this| this.bg(p.menu_row_hover)) .when_some(icon, |this, icon| { this.child(div().pt(px(2.)).child(icon.size(px(15.)).text_color(p.icon))) }) .child( v_flex() .flex_1() .min_w_0() .gap(px(2.)) .child( h_flex() .gap(px(8.)) .text_size(px(13.5)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(title.into()) .when_some(badge, |this, badge| this.child(metal::tag(badge, p))), ) .when_some(description, |this, description| { this.child(div().text_size(px(12.)).line_height(px(16.)).text_color(p.ink3).child(description)) }), ) .child( div() .w(px(14.)) .pt(px(2.)) .when(checked, |this| this.child(Icon::new(IconName::Check).size(px(14.)).text_color(p.ink))), ) } pub fn separator(p: &Palette) -> Div { div().px(px(8.)).py(px(4.)).child(metal::rule(p)) } /// The reasoning menu: effort levels the model accepts, Claude's Ultracode /// and Ultrathink, and fast mode where the model has it. pub fn reasoning_menu( session: &Entity, model: Option<&ModelEntry>, close: impl Fn(&mut Window, &mut App) + Clone + 'static, p: &Palette, cx: &App, ) -> Div { let state = session.read(cx); let current = state.reasoning; let provider = state.provider; let fast_mode = state.fast_mode; let levels: Vec = match provider { Provider::Pi if !state.pi_efforts.is_empty() => state.pi_efforts.clone(), _ => model .map(|model| model.efforts.clone()) .filter(|levels| !levels.is_empty()) .unwrap_or_else(|| vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max]), }; let pick = |reasoning: Reasoning| { let session = session.clone(); let close = close.clone(); move |window: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_reasoning(reasoning, cx)); close(window, cx); } }; let row = |id: &'static str, reasoning: Reasoning, description: Option<&'static str>| { menu_row( id, None, reasoning.label(), description.map(Into::into), None, current == reasoning, true, p, pick(reasoning), ) }; let mut menu = panel(p).w(px(300.)).child(heading( if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, p, )); if provider == Provider::Claude { menu = menu.child(row("reasoning-default", Reasoning::Default, Some("The model's own default."))); } for (ix, level) in levels.into_iter().enumerate() { menu = menu.child(menu_row( ("reasoning-level", ix), None, level.label(), None, None, current == Reasoning::Level(level), true, p, pick(Reasoning::Level(level)), )); } if provider == Provider::Claude { menu = menu .child(row( "reasoning-ultracode", Reasoning::Ultracode, Some("Extra-high effort plus multi-agent workflow orchestration."), )) .child(row( "reasoning-ultrathink", Reasoning::Ultrathink, Some("Asks for deeper reasoning on every message."), )); if model.is_some_and(|model| model.fast_mode) { let fast = |on: bool| { let session = session.clone(); let close = close.clone(); move |window: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_fast_mode(on, cx)); close(window, cx); } }; menu = menu .child(separator(p)) .child(heading("Fast mode", p)) .child(menu_row("fast-on", None, "On", None, None, fast_mode, true, p, fast(true))) .child(menu_row("fast-off", None, "Off", None, None, !fast_mode, true, p, fast(false))); } } menu } pub fn access_icon(mode: PermissionMode) -> Icon { match mode { PermissionMode::Default => Icon::new(Lucide::Lock), PermissionMode::AcceptEdits => Icon::new(Lucide::Pencil), PermissionMode::Auto => Icon::new(Lucide::Sparkles), PermissionMode::Plan => Icon::new(IconName::Map), PermissionMode::BypassPermissions => Icon::new(Lucide::LockOpen), } } /// The access menu: how much Claude may do without asking. pub fn access_menu( session: &Entity, model: Option<&ModelEntry>, close: impl Fn(&mut Window, &mut App) + Clone + 'static, p: &Palette, cx: &App, ) -> Div { let state = session.read(cx); if state.provider == Provider::Pi { return panel(p).w(px(320.)).child(menu_row( "access-pi", Some(Icon::new(Lucide::LockOpen)), "Full access", Some("Pi runs its tools without asking; it has no approval prompts.".into()), None, true, false, p, |_, _| {}, )); } let current = state.permission_mode; let auto_supported = model.is_none_or(|model| model.auto_mode); let mut menu = panel(p).w(px(320.)); for mode in PermissionMode::ALL { let enabled = mode != PermissionMode::Auto || auto_supported; let description = if enabled { mode.description().to_string() } else { "Not available for this model.".to_string() }; let session = session.clone(); let close = close.clone(); menu = menu.child(menu_row( SharedString::from(format!("access-{}", mode.as_cli())), Some(access_icon(mode)), mode.label(), Some(description.into()), None, mode == current, enabled, p, move |window, cx| { session.update(cx, |session, cx| session.set_permission_mode(mode, cx)); close(window, cx); }, )); } menu } /// Which models the picker lists: favorites, or one provider instance's. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Rail { Favorites, Instance(String), } pub enum ModelPickerEvent { Picked(ModelEntry), } /// The model picker: favorites and each provider on a rail, a search field, /// and Ctrl+1–9 for the first rows. pub struct ModelPicker { session: Entity, search: Entity, rail: Rail, legacy_open: bool, _subscription: Subscription, } impl EventEmitter for ModelPicker {} impl ModelPicker { pub fn new(session: Entity, window: &mut Window, cx: &mut Context) -> Self { let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search models…")); let subscription = cx.subscribe(&search, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } }); let rail = Rail::Instance(session.read(cx).instance.clone()); Self { session, search, rail, legacy_open: false, _subscription: subscription } } /// Resets the picker for a fresh opening and focuses its search field. pub fn opened(&mut self, window: &mut Window, cx: &mut Context) { let instance = self.session.read(cx).instance.clone(); let has_favorites = !Catalog::global(cx).favorites().is_empty(); self.rail = if has_favorites { Rail::Favorites } else { Rail::Instance(instance) }; self.search.update(cx, |search, cx| { search.set_value("", window, cx); search.focus(window, cx); }); cx.notify(); } fn visible(&self, cx: &App) -> (Vec, Vec) { let catalog = Catalog::global(cx); let query = self.search.read(cx).value().trim().to_lowercase(); if !query.is_empty() { let matches = catalog .all_models() .filter(|model| { model.name.to_lowercase().contains(&query) || model.detail.to_lowercase().contains(&query) || model.id.to_lowercase().contains(&query) }) .cloned() .collect(); return (matches, Vec::new()); } match &self.rail { Rail::Favorites => (catalog.favorites().into_iter().cloned().collect(), Vec::new()), Rail::Instance(instance) => catalog .models(instance) .iter() .cloned() .partition(|model| !model.legacy), } } fn pick(&mut self, model: ModelEntry, cx: &mut Context) { cx.emit(ModelPickerEvent::Picked(model)); } fn render_rail_button(&self, rail: Rail, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let selected = self.rail == rail; let catalog = Catalog::global(cx); let (icon, tooltip): (Icon, SharedString) = match &rail { Rail::Favorites => (Icon::new(Lucide::Star).size(px(18.)).text_color(p.ink), "Favorites".into()), Rail::Instance(id) => { let kind = catalog.instance(id).map(|state| state.kind()).unwrap_or_default(); (provider_icon(kind, px(18.), p), catalog.label(id).into()) } }; let id = match &rail { Rail::Favorites => "favorites".to_string(), Rail::Instance(id) => id.clone(), }; h_flex() .child( div() .id(SharedString::from(format!("rail-{id}"))) .flex() .items_center() .justify_center() .size(px(38.)) .rounded(px(12.)) .cursor_pointer() .when(selected, |this| this.raised(p)) .when(!selected, |this| this.hover(|style| style.bg(p.row_hover))) .tooltip(move |window, cx| gpui_kit::component::tooltip::Tooltip::new(tooltip.clone()).build(window, cx)) .on_click(cx.listener(move |this, _, _, cx| { this.rail = rail.clone(); this.legacy_open = false; cx.notify(); })) .child(icon), ) .child( div() .w(px(2.)) .h(px(18.)) .ml(px(5.)) .rounded_full() .when(selected, |this| this.bg(p.green_deep)), ) } fn render_row(&self, ix: usize, model: &ModelEntry, current: Option<&ModelEntry>, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let favorite = Catalog::global(cx).is_favorite(model); // Claude's default entry stands for the current model it resolves to. let selected = current.is_some_and(|current| { current.key() == model.key() || (current.id == "default" && !model.legacy && current.resolved == model.resolved) }); let pick = model.clone(); let star = model.clone(); let instance = Catalog::global(cx).label(&model.instance); h_flex() .id(SharedString::from(format!("model-{}", model.key()))) .gap(px(10.)) .px(px(12.)) .py(px(8.)) .rounded(px(12.)) .cursor_pointer() .map(|this| if selected { this.raised(p) } else { this.hover(|style| style.bg(p.menu_row_hover)) }) .on_click(cx.listener(move |this, _, _, cx| this.pick(pick.clone(), cx))) .child( v_flex() .flex_1() .min_w_0() .gap(px(3.)) .child( div() .truncate() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(model.name.clone()), ) .child( h_flex() .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink3) .child(provider_icon(model.provider, px(12.), p)) .child(div().truncate().child(match model.provider { Provider::Claude => instance, Provider::Pi => format!("{} · {instance}", model.detail), })), ), ) .when(ix < 9, |this| this.child(metal::shortcut(&["Ctrl", DIGITS[ix]], p))) .child( MetalButton::new(SharedString::from(format!("star-{}", model.key()))) .ghost() .mini() .icon(if favorite { Icon::new(IconName::StarFill) } else { Icon::new(Lucide::Star) }) .when(favorite, |this| this.icon_color(p.amber)) .on_click(move |_, _, cx| { cx.stop_propagation(); Catalog::toggle_favorite(&star, cx); }), ) .into_any_element() } fn render_empty(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let catalog = Catalog::global(cx); let searching = !self.search.read(cx).value().trim().is_empty(); let text: SharedString = match &self.rail { _ if searching => "No models match.".into(), Rail::Favorites => "Star a model to pin it here.".into(), Rail::Instance(id) => match catalog.instance(id) { None => "This provider was removed.".into(), Some(state) => { let label = state.label(); match (&state.status, state.kind()) { (ProbeStatus::Disabled, _) => format!("{label} is turned off in Settings → Providers.").into(), (ProbeStatus::Checking, _) => format!("Asking {label} for its models…").into(), (ProbeStatus::Missing, Provider::Pi) => { "Pi isn't installed. Install it with `npm install -g @earendil-works/pi-coding-agent` and log in to a provider." .into() } (ProbeStatus::Missing, Provider::Claude) => "Claude Code isn't installed.".into(), (ProbeStatus::Failed(error), _) => format!("{label} didn't list its models: {error}").into(), (ProbeStatus::Ready, Provider::Pi) => { "Pi has no models configured. Log in to a provider with `pi`.".into() } (ProbeStatus::Ready, Provider::Claude) => "Claude Code listed no models.".into(), } } }, }; div().px(px(12.)).py(px(14.)).text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(text) } } const DIGITS: [&str; 9] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; impl Render for ModelPicker { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let (models, legacy) = self.visible(cx); let state = self.session.read(cx); let current = Catalog::global(cx).find(&state.instance, state.model.as_deref()).cloned(); let instances: Vec = Catalog::global(cx).enabled().map(|state| state.id().to_string()).collect(); let rails: Vec = instances .into_iter() .map(|id| self.render_rail_button(Rail::Instance(id), cx).into_any_element()) .collect(); let shortcuts = models.clone(); let rows: Vec = models.iter().enumerate().map(|(ix, model)| self.render_row(ix, model, current.as_ref(), cx)).collect(); let legacy_rows: Vec = if self.legacy_open { legacy .iter() .enumerate() .map(|(ix, model)| self.render_row(models.len() + ix, model, current.as_ref(), cx)) .collect() } else { Vec::new() }; let legacy_count = legacy.len(); let empty = rows.is_empty() && legacy_count == 0; h_flex() .items_start() .w(px(460.)) .rounded(px(18.)) .bg(p.menu_bg) .shadow(p.raise.clone()) .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { let keystroke = &event.keystroke; if keystroke.modifiers.control && let Some(ix) = DIGITS.iter().position(|digit| *digit == keystroke.key) && let Some(model) = shortcuts.get(ix) { cx.stop_propagation(); this.pick(model.clone(), cx); } })) .child( v_flex() .flex_none() .gap(px(6.)) .py(px(10.)) .pl(px(9.)) .pr(px(3.)) .child(self.render_rail_button(Rail::Favorites, cx)) .child(div().mx(px(6.)).child(metal::rule(p))) .children(rails), ) .child(div().w(px(1.)).self_stretch().bg(p.rule)) .child( v_flex() .flex_1() .min_w_0() .p(px(8.)) .gap(px(6.)) .child( h_flex() .h(px(38.)) .pl(px(11.)) .pr(px(8.)) .gap(px(8.)) .rounded(px(13.)) .well(p) .text_size(px(13.5)) .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink)) .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))), ) .child( v_flex() .id("model-list") .max_h(px(380.)) .overflow_y_scroll() .gap(px(2.)) .children(rows) .when(empty, |this| this.child(self.render_empty(cx))) .when(legacy_count > 0, |this| { this.child( h_flex() .id("legacy-models") .gap(px(10.)) .px(px(12.)) .py(px(8.)) .rounded(px(12.)) .cursor_pointer() .hover(|style| style.bg(p.menu_row_hover)) .on_click(cx.listener(|this, _, _, cx| { this.legacy_open = !this.legacy_open; cx.notify(); })) .child( v_flex() .flex_1() .gap(px(3.)) .child( div() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child("Legacy models"), ) .child( div() .text_size(px(12.)) .text_color(p.ink3) .child(format!("{legacy_count} models")), ), ) .child( Icon::new(if self.legacy_open { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(14.)) .text_color(p.ink3), ), ) .children(legacy_rows) }), ), ) } }