//! 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),
Provider::Empryo => Icon::new(Lucide::Dna).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<Effort> {
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<Effort> {
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<Session>,
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<Session>,
model: Option<&ModelEntry>,
close: impl Fn(&mut Window, &mut App) + Clone + 'static,
p: &Palette,
cx: &App,
) -> Vec<AnyElement> {
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());
// Empryo leaves effort to the model unless it's told one.
if default.is_none() && matches!(provider, Provider::Claude | Provider::Empryo) {
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<PermissionMode> {
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<Session>,
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<Session>,
search: Entity<InputState>,
rail: Rail,
/// Instances whose legacy models are unfolded.
legacy_open: Vec<String>,
_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: 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<Self>) {
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<Self>) {
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<ModelEntry>, Vec<ModelEntry>) {
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<ModelEntry>, Vec<ModelEntry>) =
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<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): (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::<String>()
.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()
.flex_none()
.child(
div()
.id(SharedString::from(format!("rail-{id}")))
.flex()
.items_center()
.justify_center()
.size(px(36.))
.rounded(px(10.))
.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<usize>, model: &ModelEntry, current: Option<&ModelEntry>, cx: &mut Context<Self>) -> 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);
// As in T3 Code, a thread can't move to another provider once it started.
let state = self.session.read(cx);
let locked = !state.items.is_empty() && model.instance != state.instance;
let detail = match model.provider {
Provider::Claude => instance,
Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{instance} · {}", model.detail),
};
h_flex()
.id(SharedString::from(format!("model-{}", model.key())))
.w_full()
// The list scrolls; a row never squeezes into the next one.
.flex_none()
.min_h(px(28.))
.gap(px(6.))
.px(px(8.))
.py(px(6.))
.rounded(px(8.))
.when(selected, |this| this.bg(p.ink.opacity(0.08)))
.map(|this| {
if locked {
this.opacity(0.64).cursor_not_allowed().tooltip(|window, cx| {
Tooltip::new(
"This thread does not support switching providers after it has started. Start a new thread to use this model.",
)
.build(window, cx)
})
} else {
this.cursor_pointer()
.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<Self>) -> Option<AnyElement> {
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::Missing, Provider::Empryo) => {
"Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash".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<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 searching = self.searching(cx);
let rails: Vec<AnyElement> = 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<AnyElement> =
models.iter().enumerate().map(|(ix, model)| self.render_row(Some(ix), model, current.as_ref(), cx)).collect();
let legacy_rows: Vec<AnyElement> = 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 state = self.session.read(cx);
let (started, current_instance) = (!state.items.is_empty(), state.instance.clone());
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::<usize>().ok().filter(|digit| (1..=9).contains(digit))
&& let Some(model) = shortcuts.get(ix - 1)
&& !(started && model.instance != current_instance)
{
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()
.flex_none()
.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} model{}", if legacy_count == 1 { "" } else { "s" })),
),
)
.child(
Icon::new(if legacy_open { IconName::ChevronDown } else { IconName::ChevronRight })
.size(px(16.))
.text_color(p.ink3),
),
)
.children(legacy_rows)
}),
)
.children(setup),
)
}
}