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