//! 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, PiStatus, 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<ElementId>,
icon: Option<Icon>,
title: impl Into<SharedString>,
description: Option<SharedString>,
badge: Option<&'static str>,
checked: bool,
enabled: bool,
p: &Palette,
on_click: impl Fn(&mut Window, &mut App) + 'static,
) -> Stateful<Div> {
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<Session>,
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<Effort> = 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<Session>,
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 provider's models the picker lists.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Rail {
Favorites,
Provider(Provider),
}
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<Session>,
search: Entity<InputState>,
rail: Rail,
legacy_open: bool,
_subscription: Subscription,
}
impl EventEmitter<ModelPickerEvent> for ModelPicker {}
impl ModelPicker {
pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> 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::Provider(session.read(cx).provider);
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<Self>) {
let provider = self.session.read(cx).provider;
let has_favorites = !Catalog::global(cx).favorites().is_empty();
self.rail = if has_favorites { Rail::Favorites } else { Rail::Provider(provider) };
self.search.update(cx, |search, cx| {
search.set_value("", window, cx);
search.focus(window, cx);
});
cx.notify();
}
fn visible(&self, cx: &App) -> (Vec<ModelEntry>, Vec<ModelEntry>) {
let catalog = Catalog::global(cx);
let query = self.search.read(cx).value().trim().to_lowercase();
if !query.is_empty() {
let matches = catalog
.claude
.iter()
.chain(&catalog.pi)
.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::Provider(provider) => catalog
.models(provider)
.iter()
.cloned()
.partition(|model| !model.legacy),
}
}
fn pick(&mut self, model: ModelEntry, cx: &mut Context<Self>) {
cx.emit(ModelPickerEvent::Picked(model));
}
fn render_rail_button(&self, rail: Rail, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let selected = self.rail == rail;
let (icon, tooltip): (Icon, &'static str) = match rail {
Rail::Favorites => (Icon::new(Lucide::Star).size(px(18.)).text_color(p.ink), "Favorites"),
Rail::Provider(provider) => (provider_icon(provider, px(18.), p), provider.label()),
};
h_flex()
.child(
div()
.id(SharedString::from(format!("rail-{tooltip}")))
.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).build(window, cx))
.on_click(cx.listener(move |this, _, _, cx| {
this.rail = rail;
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<Self>) -> 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();
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 => "Claude".to_string(),
Provider::Pi => format!("{} · Pi", 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<Self>) -> 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::Provider(Provider::Pi) => match &catalog.pi_status {
PiStatus::Loading => "Asking Pi for its models…".into(),
PiStatus::Missing => {
"Pi isn't installed. Install it with `npm install -g @earendil-works/pi-coding-agent`, log in to a provider, then restart."
.into()
}
PiStatus::Failed(error) => format!("Pi didn't list its models: {error}").into(),
PiStatus::Ready => "Pi has no models configured. Log in to a provider with `pi`.".into(),
},
Rail::Provider(Provider::Claude) => "Claude's models appear once Claude Code has started.".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<Self>) -> 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.provider, state.model.as_deref()).cloned();
let shortcuts = models.clone();
let rows: Vec<AnyElement> =
models.iter().enumerate().map(|(ix, model)| self.render_row(ix, model, current.as_ref(), cx)).collect();
let legacy_rows: Vec<AnyElement> = 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)))
.child(self.render_rail_button(Rail::Provider(Provider::Claude), cx))
.child(self.render_rail_button(Rail::Provider(Provider::Pi), cx)),
)
.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)
}),
),
)
}
}