Vectrum
Sign In
783 lines · 34.9 KB · Rust
//! 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),
            )
    }
}