//! The composer's pickers, laid out like T3 Code's: a model picker spanning //! every provider (a rail of providers with favorites on top, a search //! field, and the models), the traits menu for reasoning and fast mode, and //! the access menu. use gpui_kit::component::{ Icon, IconName, h_flex, input::{Input, InputEvent, InputState}, tooltip::Tooltip, 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::composer; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, 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(4.)).gap(px(1.)).rounded(px(14.)).bg(p.menu_bg).shadow(p.raise.clone()) } /// The reasoning levels a thread's model offers. pub fn reasoning_levels(session: &Session, model: Option<&ModelEntry>) -> Vec { match session.provider { Provider::Pi if !session.pi_efforts.is_empty() => session.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]), } } /// The level `Default` stands for on this model, when known. pub fn default_level(session: &Session, model: Option<&ModelEntry>) -> Option { match session.provider { Provider::Claude => model.and_then(|model| model.claude_profile().default_effort), _ => None, } } /// Whether the model takes any trait at all: a reasoning level or fast mode. pub fn has_traits(session: &Session, model: Option<&ModelEntry>) -> bool { !reasoning_levels(session, model).is_empty() || model.is_some_and(|model| model.fast_mode) } /// The traits trigger's label, as T3 Code builds it: the reasoning level, /// with "Fast" after it while fast mode is on: `High`, `High Fast`. pub fn traits_label(session: &Session, model: Option<&ModelEntry>) -> String { let level = match session.reasoning { Reasoning::Default => default_level(session, model).map(Effort::label).unwrap_or("Default"), reasoning => reasoning.label(), }; let fast = session.fast_mode && model.is_some_and(|model| model.fast_mode); if reasoning_levels(session, model).is_empty() { return if fast { "Fast".into() } else { "Normal".into() }; } if fast { format!("{level} Fast") } else { level.to_string() } } /// The traits menu: a "Reasoning" group with each level (the model's /// default marked), Claude's Ultracode and Ultrathink, then "Fast Mode" On /// and Off where the model has it. pub fn traits_menu( session: &Entity, model: Option<&ModelEntry>, close: impl Fn(&mut Window, &mut App) + Clone + 'static, p: &Palette, cx: &App, ) -> Div { panel(p).min_w(px(160.)).children(traits_items(session, model, close, p, cx)) } /// The traits menu's groups, also listed in the composer's "…" menu when /// the traits control doesn't fit. pub fn traits_items( session: &Entity, model: Option<&ModelEntry>, close: impl Fn(&mut Window, &mut App) + Clone + 'static, p: &Palette, cx: &App, ) -> Vec { let state = session.read(cx); let provider = state.provider; let levels = reasoning_levels(state, model); let default = default_level(state, model); let current = match state.reasoning { Reasoning::Default => default.map(Reasoning::Level).unwrap_or(Reasoning::Default), reasoning => reasoning, }; let fast_mode = state.fast_mode; let profile = model.map(ModelEntry::claude_profile); let pick = |reasoning: Reasoning| { let session = session.clone(); let close = close.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_reasoning(reasoning, cx)); close(window, cx); } }; let item = |id: ElementId, reasoning: Reasoning, label: &'static str, description: Option<&'static str>, is_default: bool| { composer::menu_item(id, current == reasoning, true, p) .on_click(pick(reasoning)) .child( v_flex() .min_w_0() .child( h_flex() .gap(px(6.)) .child(div().truncate().child(label)) .when(is_default, |this| this.child(composer::outline_badge("Default", p))), ) .when_some(description, |this, description| { this.child(div().max_w(px(224.)).text_size(px(12.)).text_color(p.ink3.opacity(0.8)).child(description)) }), ) .into_any_element() }; let mut items = Vec::new(); if !levels.is_empty() || provider == Provider::Claude { items.push(composer::menu_label(if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, p).into_any_element()); if default.is_none() && provider == Provider::Claude { items.push(item("traits-default".into(), Reasoning::Default, "Default", None, true)); } for (ix, level) in levels.into_iter().enumerate() { items.push(item( ElementId::NamedInteger("traits-level".into(), ix as u64), Reasoning::Level(level), level.label(), None, default == Some(level), )); } if provider == Provider::Claude { if profile.as_ref().is_none_or(|profile| profile.ultracode) { items.push(item( "traits-ultracode".into(), Reasoning::Ultracode, "Ultracode", Some("xhigh effort plus multi-agent workflow orchestration"), false, )); } items.push(item("traits-ultrathink".into(), Reasoning::Ultrathink, "Ultrathink", None, false)); } } if model.is_some_and(|model| model.fast_mode) { let fast = |on: bool| { let session = session.clone(); let close = close.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_fast_mode(on, cx)); close(window, cx); } }; if !items.is_empty() { items.push(composer::menu_separator(p).into_any_element()); } items.push(composer::menu_label("Fast Mode", p).into_any_element()); items.push(composer::menu_item("fast-on", fast_mode, true, p).on_click(fast(true)).child("On").into_any_element()); items.push(composer::menu_item("fast-off", !fast_mode, true, p).on_click(fast(false)).child("Off").into_any_element()); } items } pub fn access_icon(mode: PermissionMode) -> Icon { match mode { PermissionMode::Default => Icon::new(Lucide::Lock), PermissionMode::AcceptEdits => Icon::new(Lucide::PenLine), PermissionMode::Auto => Icon::new(Lucide::Sparkles), PermissionMode::Plan => Icon::new(Lucide::PencilRuler), PermissionMode::BypassPermissions => Icon::new(Lucide::LockOpen), } } /// The access modes a thread can pick from: T3 Code's four runtime modes, /// or only Full access for agents without approval prompts. pub fn access_modes(provider: Provider) -> Vec { if provider.asks_permission() { vec![ PermissionMode::Default, PermissionMode::AcceptEdits, PermissionMode::Auto, PermissionMode::BypassPermissions, ] } else { vec![PermissionMode::BypassPermissions] } } /// The access menu: each mode with its icon, label and description. pub fn access_menu( session: &Entity, model: Option<&ModelEntry>, current: PermissionMode, close: impl Fn(&mut Window, &mut App) + Clone + 'static, p: &Palette, cx: &App, ) -> Div { let state = session.read(cx); let provider = state.provider; let auto_supported = model.is_none_or(|model| model.auto_mode); let mut menu = panel(p); for mode in access_modes(provider) { let enabled = mode != PermissionMode::Auto || auto_supported; let description = if !provider.asks_permission() { format!("{} runs its tools without asking; it has no approval prompts.", provider.agent_name()) } else 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( composer::menu_item(SharedString::from(format!("access-{}", mode.as_cli())), mode == current, enabled, p) .min_w(px(256.)) .when(enabled && provider.asks_permission(), |this| { this.on_click(move |_, window, cx| { session.update(cx, |session, cx| session.set_access_mode(mode, cx)); close(window, cx); }) }) .child( v_flex() .gap(px(2.)) .child( h_flex() .gap(px(6.)) .font_weight(FontWeight::MEDIUM) .child(access_icon(mode).size(px(14.)).text_color(p.ink3)) .child(mode.label()), ) .child(div().text_size(px(12.)).line_height(px(16.)).text_color(p.ink3).child(description)), ), ); } 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, /// Instances whose legacy models are unfolded. legacy_open: Vec, _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: Vec::new(), _subscription: subscription } } /// Resets the picker for a fresh opening and focuses its search field: /// on Favorites when there are any, otherwise on the thread's provider. pub fn opened(&mut self, window: &mut Window, cx: &mut Context) { let state = self.session.read(cx); let instance = state.instance.clone(); let catalog = Catalog::global(cx); let current = catalog.find(&instance, state.model.as_deref()); let has_favorites = !catalog.favorites().is_empty(); self.rail = if has_favorites { Rail::Favorites } else { Rail::Instance(instance.clone()) }; self.legacy_open = current.filter(|model| model.legacy).map(|_| vec![instance]).unwrap_or_default(); self.search.update(cx, |search, cx| { search.set_value("", window, cx); search.focus(window, cx); }); cx.notify(); } /// Moves the rail to the previous or next provider (Ctrl+Shift+↑/↓). pub fn cycle_rail(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { let mut rails = vec![Rail::Favorites]; rails.extend(Catalog::global(cx).enabled().map(|state| Rail::Instance(state.id().to_string()))); let at = rails.iter().position(|rail| *rail == self.rail).unwrap_or(0); let next = if forward { (at + 1) % rails.len() } else { (at + rails.len() - 1) % rails.len() }; self.rail = rails[next].clone(); self.search.update(cx, |search, cx| { search.set_value("", window, cx); search.focus(window, cx); }); cx.notify(); } fn searching(&self, cx: &App) -> bool { !self.search.read(cx).value().trim().is_empty() } /// The rows the list shows: the models, and whether a "Legacy models" /// toggle follows them (with how many it hides) before the legacy ones. 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() { // Every word must match somewhere; favorites rank first. let words: Vec<&str> = query.split_whitespace().collect(); let mut matches: Vec<(bool, ModelEntry)> = catalog .all_models() .filter(|model| !catalog.is_hidden(model)) .filter(|model| { let haystack = format!( "{} {} {} {} {}", model.name, model.detail, model.id, model.provider.label(), catalog.label(&model.instance) ) .to_lowercase(); words.iter().all(|word| haystack.contains(word)) }) .map(|model| (!catalog.is_favorite(model), model.clone())) .collect(); matches.sort_by_key(|(not_favorite, model)| (*not_favorite, !model.name.to_lowercase().starts_with(words[0]))); return (matches.into_iter().map(|(_, model)| model).collect(), Vec::new()); } match &self.rail { Rail::Favorites => (catalog.favorites().into_iter().cloned().collect(), Vec::new()), Rail::Instance(instance) => { // Models hidden in Settings → Providers stay out of the picker. let (mut current, legacy): (Vec, Vec) = catalog.models(instance).iter().filter(|model| !catalog.is_hidden(model)).cloned().partition(|model| !model.legacy); // Favorites float to the top, the rest keep the provider's order. current.sort_by_key(|model| !catalog.is_favorite(model)); (current, 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): (AnyElement, SharedString) = match &rail { Rail::Favorites => ( Icon::new(IconName::StarFill).size(px(20.)).text_color(p.ink).into_any_element(), "Favorites".into(), ), Rail::Instance(id) => { let state = catalog.instance(id); let kind = state.map(|state| state.kind()).unwrap_or_default(); let label = catalog.label(id); let tooltip = match state.map(|state| &state.status) { Some(ProbeStatus::Ready) | None => label, Some(ProbeStatus::Disabled) => format!("{label} — Disabled in settings."), Some(ProbeStatus::Failed(error)) => format!("{label} — Unavailable. {error}"), Some(ProbeStatus::Missing) => format!("{label} — Unavailable. Not installed"), Some(ProbeStatus::Checking) => format!("{label} — Not ready."), }; // A badge with the instance's initials tells two instances of a kind apart. let shared = catalog.enabled().filter(|other| other.kind() == kind).count() > 1; let initials: String = catalog .label(id) .split_whitespace() .take(2) .filter_map(|word| word.chars().next()) .collect::() .to_uppercase(); ( div() .relative() .size(px(24.)) .flex() .items_center() .justify_center() .child(provider_icon(kind, px(20.), p)) .when(shared, |this| { this.child( div() .absolute() .right(px(-2.)) .bottom(px(-2.)) .h(px(12.)) .min_w(px(12.)) .px(px(2.)) .rounded_full() .bg(p.menu_bg) .shadow(p.raise_sm.clone()) .text_size(px(7.)) .font_weight(FontWeight::SEMIBOLD) .text_color(p.ink3) .flex() .items_center() .justify_center() .child(initials), ) }) .into_any_element(), tooltip.into(), ) } }; let id = match &rail { Rail::Favorites => "favorites".to_string(), Rail::Instance(id) => id.clone(), }; div() .relative() .w_full() .child( div() .id(SharedString::from(format!("rail-{id}"))) .flex() .items_center() .justify_center() .size(px(36.)) .rounded(px(8.)) .cursor_pointer() .hover(|style| style.bg(p.ink.opacity(0.1))) .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) .on_click(cx.listener(move |this, _, window, cx| { this.rail = rail.clone(); this.search.update(cx, |search, cx| search.focus(window, cx)); cx.notify(); })) .child(icon), ) .when(selected, |this| { this.child( div() .absolute() .right(px(-4.)) .top(px(8.)) .w(px(3.)) .h(px(20.)) .rounded_l_full() .bg(p.green_deep), ) }) } fn render_row(&self, ix: Option, model: &ModelEntry, current: Option<&ModelEntry>, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let catalog = Catalog::global(cx); let favorite = catalog.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.label(&model.instance); let detail = match model.provider { Provider::Claude => instance, Provider::Pi | Provider::Jcode => format!("{instance} · {}", model.detail), }; h_flex() .id(SharedString::from(format!("model-{}", model.key()))) .w_full() .min_h(px(28.)) .gap(px(6.)) .px(px(8.)) .py(px(6.)) .rounded(px(8.)) .cursor_pointer() .when(selected, |this| this.bg(p.ink.opacity(0.08))) .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() .child( h_flex() .gap(px(8.)) .child( div() .truncate() .text_size(px(12.)) .line_height(px(16.5)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child(model.name.clone()), ) .when(model.claude_profile().new, |this| { this.child( div() .px(px(2.)) .py(px(1.)) .rounded(px(4.)) .bg(p.blue_ink.opacity(0.15)) .text_size(px(10.)) .font_weight(FontWeight::BOLD) .text_color(p.blue_ink) .child("NEW"), ) }), ) .child( h_flex() .mt(px(4.)) .gap(px(6.)) .text_size(px(12.)) .text_color(p.ink3.opacity(0.7)) .child(provider_icon(model.provider, px(12.), p)) .child(div().truncate().child(detail)), ), ) .when_some(ix.filter(|ix| *ix < 9), |this, ix| this.child(metal::keycap(format!("Ctrl+{}", ix + 1), p))) .child( MetalButton::new(SharedString::from(format!("star-{}", model.key()))) .ghost() .mini() .size(px(24.)) .mr(px(-4.)) .icon(if favorite { Icon::new(IconName::StarFill) } else { Icon::new(Lucide::Star) }) .when(favorite, |this| this.icon_color(p.amber)) .tooltip(if favorite { "Remove from favorites" } else { "Add to favorites" }) .on_click(move |_, _, cx| { cx.stop_propagation(); Catalog::toggle_favorite(&star, cx); }), ) .into_any_element() } fn render_setup(&self, cx: &mut Context) -> Option { let p = metal::palette(cx); let Rail::Instance(id) = &self.rail else { return None }; let catalog = Catalog::global(cx); let state = catalog.instance(id)?; let label = state.label(); let text: String = match (&state.status, state.kind()) { (ProbeStatus::Ready, _) if !state.models.is_empty() => return None, (ProbeStatus::Disabled, _) => format!("{label} is turned off in Settings → Providers."), (ProbeStatus::Checking, _) => format!("Asking {label} for its models…"), (ProbeStatus::Missing, Provider::Pi) => { "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent".into() } (ProbeStatus::Missing, Provider::Claude) => "Open provider setup to install Claude on this environment.".into(), (ProbeStatus::Missing, Provider::Jcode) => "Open provider setup to install jcode on this environment.".into(), (ProbeStatus::Failed(error), _) => format!("{label} provider is unavailable. {error}"), (ProbeStatus::Ready, _) => "No models are available for this provider.".into(), }; Some( div() .border_t_1() .border_color(p.rule) .p(px(8.)) .child(div().px(px(4.)).py(px(6.)).text_size(px(12.)).line_height(px(16.5)).text_color(p.ink3).child(text)) .into_any_element(), ) } } 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 searching = self.searching(cx); let rails: Vec = instances .into_iter() .map(|id| self.render_rail_button(Rail::Instance(id), cx).into_any_element()) .collect(); let legacy_instance = match &self.rail { Rail::Instance(id) => id.clone(), Rail::Favorites => String::new(), }; let legacy_open = self.legacy_open.contains(&legacy_instance); let mut shortcuts = models.clone(); if legacy_open { shortcuts.extend(legacy.iter().cloned()); } let rows: Vec = models.iter().enumerate().map(|(ix, model)| self.render_row(Some(ix), model, current.as_ref(), cx)).collect(); let legacy_rows: Vec = if legacy_open { legacy .iter() .enumerate() .map(|(ix, model)| self.render_row(Some(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; let setup = if searching { None } else { self.render_setup(cx) }; h_flex() .items_start() .w(px(360.)) .max_h(px(346.)) .overflow_hidden() .rounded(px(14.)) .bg(p.menu_bg) .shadow(p.raise.clone()) .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| { let keystroke = &event.keystroke; let modifiers = &keystroke.modifiers; if modifiers.control && modifiers.shift && matches!(keystroke.key.as_str(), "up" | "down") { cx.stop_propagation(); this.cycle_rail(keystroke.key == "down", window, cx); } else if modifiers.control && !modifiers.shift && let Some(ix) = keystroke.key.parse::().ok().filter(|digit| (1..=9).contains(digit)) && let Some(model) = shortcuts.get(ix - 1) { cx.stop_propagation(); this.pick(model.clone(), cx); } })) .when(!searching, |this| { this.child( v_flex() .id("model-rail") .flex_none() .w(px(44.)) .h_full() .p(px(4.)) .gap(px(4.)) .bg(p.ink.opacity(0.03)) .overflow_y_scroll() .child(self.render_rail_button(Rail::Favorites, cx)) .child(div().h(px(1.)).flex_none().bg(p.rule)) .children(rails), ) .child(div().w(px(1.)).self_stretch().bg(p.rule.opacity(0.7))) }) .child( v_flex() .flex_1() .min_w_0() .bg(p.ink.opacity(0.02)) .child( div().px(px(12.)).pt(px(10.)).child( h_flex() .pb(px(6.)) .gap(px(4.)) .border_b_1() .border_color(p.rule) .text_size(px(14.)) .child(Icon::new(IconName::Search).size(px(16.)).text_color(p.ink3.opacity(0.55))) .child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))), ), ) .child( v_flex() .id("model-list") .max_h(px(290.)) .overflow_y_scroll() .pl(px(8.)) .pr(px(4.)) .py(px(6.)) .gap(px(2.)) .children(rows) .when(empty && setup.is_none(), |this| { this.child( div() .p(px(8.)) .text_center() .text_size(px(14.)) .text_color(p.ink3) .child("No models found"), ) }) .when(legacy_count > 0, |this| { this.child( h_flex() .id("legacy-models") .w_full() .gap(px(8.)) .px(px(8.)) .py(px(6.)) .rounded(px(8.)) .cursor_pointer() .hover(|style| style.bg(p.menu_row_hover)) .on_click(cx.listener(move |this, _, _, cx| { let instance = legacy_instance.clone(); match this.legacy_open.iter().position(|open| *open == instance) { Some(at) => { this.legacy_open.remove(at); } None => this.legacy_open.push(instance), } cx.notify(); })) .child( v_flex() .flex_1() .child( div() .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink) .child("Legacy models"), ) .child( div() .mt(px(4.)) .text_size(px(12.)) .text_color(p.ink3.opacity(0.7)) .child(format!("{legacy_count} models")), ), ) .child( Icon::new(if legacy_open { IconName::ChevronDown } else { IconName::ChevronRight }) .size(px(16.)) .text_color(p.ink3), ), ) .children(legacy_rows) }), ) .children(setup), ) } }