//! 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),
Provider::Jcode => Icon::new(Lucide::Feather).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(),
// Most of jcode's providers take no effort setting at all.
Provider::Jcode => model.map(|model| model.efforts.clone()).unwrap_or_default(),
_ => 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::Pi {
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.asks_permission() {
let agent = state.provider.agent_name();
return panel(p).w(px(320.)).child(menu_row(
"access-full",
Some(Icon::new(Lucide::LockOpen)),
"Full access",
Some(format!("{agent} 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<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::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<Self>) {
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<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
.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<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 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<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();
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 | Provider::Jcode => 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<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::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::Missing, Provider::Jcode) => {
"jcode isn't installed. Install it with `curl -fsSL https://jcode.sh/install | bash` and run `jcode login`."
.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(),
(ProbeStatus::Ready, Provider::Jcode) => {
"jcode has no usable models. Log in to a provider with `jcode login`.".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.instance, state.model.as_deref()).cloned();
let instances: Vec<String> = Catalog::global(cx).enabled().map(|state| state.id().to_string()).collect();
let rails: Vec<AnyElement> = instances
.into_iter()
.map(|id| self.render_rail_button(Rail::Instance(id), cx).into_any_element())
.collect();
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)))
.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)
}),
),
)
}
}