//! The Providers page in T3 Code's two-pane layout: the agent instances on //! the left, each with its status and a switch, and the selected one's //! editor on the right (display name and accent color, account, runtime //! fields, environment variables and models). "Add provider" opens a //! three-step wizard. Below: usage hubs and the health check interval. use std::{path::Path, process::Stdio}; use gpui_kit::component::{ Icon, IconName, WindowExt as _, h_flex, input::{InputEvent, InputState}, notification::Notification, tooltip::Tooltip, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::SettingsView; use super::widgets::{Section, SettingRow, WIDE_WIDTH, ago, code, content_width, focus_later, note, reset, search_field, stepper, text_field}; use crate::claude; use crate::models::{self, Catalog, InstanceState, ModelEntry, ProbeStatus, Provider}; use crate::settings::{self, CustomModel, EnvVar, ProviderInstance}; use crate::ui::chat::display_path; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers; /// The list pane's width. const LIST_WIDTH: f32 = 272.; /// One environment variable's fields. struct EnvField { key: Entity, value: Entity, sensitive: bool, /// The name its secret is stored under, to move it when renamed. saved_key: String, } /// The fields of the instance open on the page. struct ProviderForm { id: String, name: Entity, binary: Entity, home: Entity, auto_compact: Entity, args: Entity, accent: Entity, env: Vec, model_filter: Entity, custom_slug: Entity, adding_custom: bool, custom_error: Option, /// The custom model whose display name is being edited, and the field. editing_custom: Option<(String, Entity)>, accent_open: bool, auto_compact_error: bool, _subscriptions: Vec, } /// The "Add provider" wizard. struct Wizard { step: usize, kind: Provider, label: Entity, id: Entity, accent: String, binary: Entity, home: Entity, args: Entity, error: Option, } #[derive(Default)] pub(super) struct ProvidersState { form: Option, wizard: Option, /// Instances an update is running for. updating: Vec, } /// T3 Code's status headline and detail for an instance. fn status_headline(state: &InstanceState) -> (Led, String) { match &state.status { ProbeStatus::Disabled => (Led::Off, "Disabled".into()), ProbeStatus::Checking => (Led::Waiting, "Checking provider status".into()), ProbeStatus::Missing => (Led::Failed, "Not found · CLI not detected on PATH.".into()), ProbeStatus::Failed(error) => (Led::Failed, format!("Unavailable · {error}")), ProbeStatus::Ready => { let mut parts = Vec::new(); match state.kind() { Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()), Provider::Claude => parts.push("Not authenticated".to_string()), Provider::Pi | Provider::Jcode if state.models.is_empty() => parts.push("Needs attention · No models configured".into()), Provider::Pi | Provider::Jcode => parts.push("Available".to_string()), } parts.extend(state.plan.clone()); let led = if parts[0].starts_with("Not") || parts[0].starts_with("Needs") { Led::Waiting } else { Led::Live }; (led, parts.join(" · ")) } } } /// `#3e9bfa` for a color. fn hex_of(color: Hsla) -> String { let rgba = color.to_rgb(); let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8; format!("#{:02x}{:02x}{:02x}", channel(rgba.r), channel(rgba.g), channel(rgba.b)) } /// A saved accent color, if it parses. fn accent_color(hex: &str) -> Option { let digits = hex.trim().trim_start_matches('#'); (matches!(digits.len(), 3 | 6) && digits.chars().all(|c| c.is_ascii_hexdigit())).then(|| metal::hex(&format!("#{digits}"))) } /// The colors the accent picker offers: the theme's tints and accents. fn accent_choices(p: &Palette) -> Vec { let mut colors = p.tints.to_vec(); colors.extend([p.green_deep, p.amber, p.red]); colors } /// An instance's icon, with a dot of its accent color. fn instance_icon(kind: Provider, accent: &str, size: f32, p: &Palette) -> Div { div() .relative() .flex_none() .child(pickers::provider_icon(kind, px(size), p)) .when_some(accent_color(accent), |this, color| { this.child( div() .absolute() .right(px(-3.)) .bottom(px(-3.)) .size(px(8.)) .rounded_full() .bg(color) .shadow(vec![BoxShadow::new(px(0.), px(0.), p.s_hi).spread_radius(px(1.5))]), ) }) } fn home_title(kind: Provider) -> &'static str { match kind { Provider::Claude => "CLAUDE_CONFIG_DIR path", Provider::Pi => "PI_CODING_AGENT_DIR path", Provider::Jcode => "JCODE_HOME path", } } fn home_placeholder(kind: Provider) -> &'static str { match kind { Provider::Claude => "~/.claude", Provider::Pi => "~/.pi/agent", Provider::Jcode => "~/.jcode", } } fn custom_placeholder(kind: Provider) -> &'static str { match kind { Provider::Claude => "claude-sonnet-5", Provider::Pi => "anthropic/claude-sonnet-5", Provider::Jcode => "claude-oauth:claude-opus-5", } } impl SettingsView { fn instance_input(&self, value: &str, placeholder: &str, window: &mut Window, cx: &mut Context) -> Entity { let (value, placeholder) = (value.to_string(), placeholder.to_string()); cx.new(|cx| InputState::new(window, cx).placeholder(placeholder).default_value(value)) } /// Opens an instance's fields on the page. fn select_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let Some(instance) = settings::get(|settings| settings.instance(id).cloned()) else { self.providers.form = None; cx.notify(); return; }; let kind = instance.kind; let name = self.instance_input(&instance.name, kind.label(), window, cx); let binary = self.instance_input(&instance.binary, kind.key(), window, cx); let home = self.instance_input(&instance.home, home_placeholder(kind), window, cx); let auto_compact = self.instance_input(&instance.auto_compact, "e.g. 300000", window, cx); let args = self.instance_input(&instance.args, if kind == Provider::Claude { "e.g. --chrome" } else { "" }, window, cx); let accent = self.instance_input(&instance.accent, "#2563eb", window, cx); let env: Vec = instance .env .iter() .map(|var| { let has_secret = var.sensitive && settings::has_secret(&instance.id, &var.key); let placeholder = if has_secret { "Stored secret, enter a new value to replace" } else { "value" }; let value = cx.new(|cx| { InputState::new(window, cx).placeholder(placeholder).default_value(var.value.clone()).masked(var.sensitive) }); EnvField { key: self.instance_input(&var.key, "VARIABLE_NAME", window, cx), value, sensitive: var.sensitive, saved_key: var.key.clone(), } }) .collect(); let model_filter = self.instance_input("", "Filter models", window, cx); let custom_slug = self.instance_input("", custom_placeholder(kind), window, cx); let mut subscriptions = Vec::new(); let fields = [&name, &binary, &home, &auto_compact, &args, &accent] .into_iter() .chain(env.iter().flat_map(|field| [&field.key, &field.value])); for field in fields { subscriptions.push(cx.subscribe_in(field, window, |this, _, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { this.save_provider_form(window, cx); } })); } subscriptions.push(cx.subscribe_in(&model_filter, window, |_, _, event: &InputEvent, _, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } })); subscriptions.push(cx.subscribe_in(&custom_slug, window, |this, _, event: &InputEvent, window, cx| { if matches!(event, InputEvent::PressEnter { .. }) { this.add_custom_model(window, cx); } })); self.providers.form = Some(ProviderForm { id: instance.id, name, binary, home, auto_compact, args, accent, env, model_filter, custom_slug, adding_custom: false, custom_error: None, editing_custom: None, accent_open: false, auto_compact_error: false, _subscriptions: subscriptions, }); cx.notify(); } /// Rebuilds the open instance's fields from the saved settings. pub(super) fn reload_provider_form(&mut self, window: &mut Window, cx: &mut Context) { if let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) { self.providers.form = None; self.select_provider(&id, window, cx); } } /// Saves the open instance's fields, if they changed. fn save_provider_form(&mut self, window: &mut Window, cx: &mut Context) { let Some(form) = self.providers.form.as_mut() else { return }; let value = |input: &Entity, cx: &App| input.read(cx).value().trim().to_string(); let auto_compact = value(&form.auto_compact, cx); form.auto_compact_error = !auto_compact.is_empty() && claude::auto_compact_window(&auto_compact).is_none(); let accent = value(&form.accent, cx); let accent = if accent.is_empty() || accent_color(&accent).is_some() { accent } else { String::new() }; let id = form.id.clone(); let (name, binary, home, args) = (value(&form.name, cx), value(&form.binary, cx), value(&form.home, cx), value(&form.args, cx)); // Sensitive values go to the secrets file, not the settings. let mut env = Vec::new(); for field in &mut form.env { let key = value(&field.key, cx); let typed = field.value.read(cx).value().to_string(); if field.sensitive { if key != field.saved_key { settings::rename_secret(&id, &field.saved_key, &key); } if !typed.is_empty() { settings::set_secret(&id, &key, Some(&typed)); field.value.update(cx, |input, cx| { input.set_value("", window, cx); input.set_placeholder("Stored secret, enter a new value to replace", window, cx); }); } field.saved_key = key.clone(); env.push(EnvVar { key, value: String::new(), sensitive: true }); } else { env.push(EnvVar { key, value: typed, sensitive: false }); } } let unchanged = settings::get(|settings| { settings.instance(&id).is_some_and(|instance| { instance.name == name && instance.binary == binary && instance.home == home && instance.args == args && instance.env == env && instance.auto_compact == auto_compact && instance.accent == accent }) }); if unchanged { cx.notify(); return; } settings::update(|settings| { if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) { instance.name = name; instance.binary = binary; instance.home = home; instance.args = args; instance.env = env; instance.auto_compact = auto_compact; instance.accent = accent; } }); Catalog::sync_instances(cx); self.changed(window, cx); } fn edit_instance(&mut self, id: &str, window: &mut Window, cx: &mut Context, change: impl FnOnce(&mut ProviderInstance)) { settings::update(|settings| { if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) { change(instance); } }); Catalog::sync_instances(cx); self.changed(window, cx); } fn remove_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let id = id.to_string(); settings::update(|settings| settings.providers.retain(|instance| instance.id != id)); Catalog::sync_instances(cx); self.providers.form = None; if let Some(first) = settings::instances().first() { self.select_provider(&first.id.clone(), window, cx); } self.changed(window, cx); } fn add_env_var(&mut self, window: &mut Window, cx: &mut Context) { let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return }; self.save_provider_form(window, cx); self.edit_instance(&id, window, cx, |instance| instance.env.push(EnvVar::default())); self.select_provider(&id, window, cx); } fn remove_env_var(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return }; self.save_provider_form(window, cx); self.edit_instance(&id, window, cx, |instance| { if ix < instance.env.len() { let var = instance.env.remove(ix); if var.sensitive { settings::set_secret(&instance.id, &var.key, None); } } }); self.select_provider(&id, window, cx); } /// Flips a variable between plain and sensitive. A value turning /// sensitive moves to the secrets file; a secret turning plain is /// forgotten, to be typed in again. fn toggle_sensitive(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(form) = self.providers.form.as_mut() else { return }; let Some(field) = form.env.get_mut(ix) else { return }; let id = form.id.clone(); let key = field.key.read(cx).value().trim().to_string(); let typed = field.value.read(cx).value().to_string(); let sensitive = !field.sensitive; self.edit_instance(&id, window, cx, |instance| { if let Some(var) = instance.env.get_mut(ix) { var.key = key.clone(); var.sensitive = sensitive; if sensitive { if !typed.is_empty() { settings::set_secret(&instance.id, &key, Some(&typed)); } var.value.clear(); } else { settings::set_secret(&instance.id, &key, None); var.value.clear(); } } }); self.select_provider(&id, window, cx); } /// Runs the agent's own updater in the background, then checks it again. fn update_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let Some(config) = settings::get(|settings| settings.instance(id).cloned()) else { return }; let Some((mut command, description)) = models::update_command(&config) else { return }; self.providers.updating.push(id.to_string()); cx.notify(); let task = cx.background_executor().spawn(async move { command.stdin(Stdio::null()).output().map_err(|err| err.to_string()).and_then(|output| { if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(stderr.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("failed").trim().to_string()) } }) }); let id = id.to_string(); cx.spawn_in(window, async move |this, cx| { let result = task.await; let _ = this.update_in(cx, |this, window, cx| { this.providers.updating.retain(|updating| *updating != id); let note = match result { Ok(()) => Notification::success(format!("Updated with {description}.")), Err(err) => Notification::error(format!("{description} failed: {err}")), }; window.push_notification(note, cx); Catalog::refresh(&id, cx); cx.notify(); }); }) .detach(); } // ---- models -------------------------------------------------------- fn add_custom_model(&mut self, window: &mut Window, cx: &mut Context) { let Some(form) = self.providers.form.as_mut() else { return }; let slug = form.custom_slug.read(cx).value().trim().to_string(); let id = form.id.clone(); let error = if slug.is_empty() { Some("Enter a model slug.") } else if Catalog::global(cx).instance(&id).is_some_and(|state| { state.models.iter().any(|model| model.id == slug && !state.is_custom(model)) }) { Some("That model is already built in.") } else if settings::get(|settings| { settings.instance(&id).is_some_and(|instance| instance.custom_models.iter().any(|custom| custom.slug == slug)) }) { Some("That custom model is already saved.") } else { None }; if let Some(error) = error { form.custom_error = Some(error.into()); cx.notify(); return; } form.adding_custom = false; form.custom_error = None; form.custom_slug.update(cx, |input, cx| input.set_value("", window, cx)); self.edit_instance(&id, window, cx, |instance| instance.custom_models.push(CustomModel { slug, name: String::new() })); } /// Opens the display name field under a custom model. fn edit_custom_model(&mut self, slug: String, window: &mut Window, cx: &mut Context) { let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return }; let name = settings::get(|settings| { settings .instance(&id) .and_then(|instance| instance.custom_models.iter().find(|custom| custom.slug == slug)) .map(|custom| custom.name.clone()) }) .unwrap_or_default(); let input = self.instance_input(&name, &slug, window, cx); let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| { if matches!(event, InputEvent::PressEnter { .. }) { this.save_custom_name(window, cx); } }); focus_later(&input, window); if let Some(form) = self.providers.form.as_mut() { form.editing_custom = Some((slug, input)); form._subscriptions.push(subscription); } cx.notify(); } fn save_custom_name(&mut self, window: &mut Window, cx: &mut Context) { let Some(form) = self.providers.form.as_mut() else { return }; let Some((slug, input)) = form.editing_custom.take() else { return }; let name = input.read(cx).value().trim().to_string(); let id = form.id.clone(); self.edit_instance(&id, window, cx, |instance| { if let Some(custom) = instance.custom_models.iter_mut().find(|custom| custom.slug == slug) { custom.name = name; } }); } fn remove_custom_model(&mut self, slug: String, window: &mut Window, cx: &mut Context) { let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return }; self.edit_instance(&id, window, cx, |instance| instance.custom_models.retain(|custom| custom.slug != slug)); } /// Moves a model up (-1) or down (+1) among its instance's models. fn move_model(&mut self, keys: Vec, ix: usize, by: isize, window: &mut Window, cx: &mut Context) { let target = ix as isize + by; if target < 0 || target as usize >= keys.len() { return; } let mut keys = keys; keys.swap(ix, target as usize); settings::update(|settings| { settings.model_order.retain(|key| !keys.contains(key)); settings.model_order.extend(keys); }); Catalog::rearrange(cx); self.changed(window, cx); } fn set_hidden(&mut self, keys: Vec, hidden: bool, window: &mut Window, cx: &mut Context) { settings::update(|settings| { settings.hidden_models.retain(|key| !keys.contains(key)); if hidden { settings.hidden_models.extend(keys); } }); self.changed(window, cx); } // ---- page ---------------------------------------------------------- pub(super) fn render_providers(&mut self, window: &mut Window, cx: &mut Context) -> Vec { let p = metal::palette(cx); let catalog = Catalog::global(cx); let instances: Vec = catalog.instances.clone(); let selected = self .providers .form .as_ref() .map(|form| form.id.clone()) .filter(|id| instances.iter().any(|state| state.id() == id)) .or_else(|| instances.first().map(|state| state.id().to_string())); let checked = instances.iter().filter_map(|state| state.checked).max(); let checking = instances.iter().any(|state| state.checking); let view = cx.weak_entity(); // Below 768px the list sits over the editor, as in T3 Code. let two_panes = content_width(window, WIDE_WIDTH) >= 768.; let list = v_flex() .map(|this| if two_panes { this.w(px(LIST_WIDTH)).self_stretch().rounded_l(px(18.)) } else { this.w_full().rounded_t(px(18.)) }) .flex_none() .region(p) .children(instances.iter().map(|state| { let id = state.id().to_string(); let is_selected = selected.as_deref() == Some(id.as_str()); let enabled = state.config.enabled; let (led, headline) = status_headline(state); let (toggle_id, select_id) = (id.clone(), id.clone()); let toggle_view = view.clone(); h_flex() .id(SharedString::from(format!("provider-{id}"))) .min_h(px(72.)) .px(px(16.)) .py(px(12.)) .gap(px(12.)) .cursor_pointer() .when(!enabled, |this| this.opacity(0.6)) .map(|this| if is_selected { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) }) .on_click(cx.listener(move |this, _, window, cx| this.select_provider(&select_id, window, cx))) .child(instance_icon(state.kind(), &state.config.accent, 20., p)) .child( v_flex() .flex_1() .min_w_0() .gap(px(3.)) .child( h_flex() .gap(px(6.)) .child(div().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(state.label())) .when_some(state.version.clone(), |this, version| this.child(code(version, p).max_w(px(96.)).truncate())) .when(state.update_available(), |this| { this.child(Icon::new(IconName::ArrowUp).size(px(12.)).text_color(p.amber_ink)) }), ) .child( h_flex() .gap(px(6.)) .child(metal::led(SharedString::from(format!("provider-led-{id}")), led, state.checking, p)) .child(div().truncate().text_size(px(12.)).text_color(p.ink3).child(headline)), ), ) .child(metal::toggle(SharedString::from(format!("provider-enabled-{id}")), enabled, p, move |on, window, cx| { let id = toggle_id.clone(); let _ = toggle_view.update(cx, |this, cx| this.edit_instance(&id, window, cx, |instance| instance.enabled = on)); })) .into_any_element() })) .child( h_flex() .id("add-provider-row") .min_h(px(48.)) .px(px(16.)) .gap(px(8.)) .cursor_pointer() .text_size(px(13.5)) .text_color(p.ink2) .hover(|style| style.bg(p.row_hover).text_color(p.ink)) .on_click(cx.listener(|this, _, window, cx| this.open_wizard(window, cx))) .child(Icon::new(Lucide::Plus).size(px(14.))) .child("Add provider"), ); let detail = match (selected.as_deref().and_then(|id| catalog.instance(id)).cloned(), &self.providers.form) { (Some(state), Some(form)) if form.id == state.id() => self.render_provider_editor(&state, window, cx), (Some(state), _) => { // The form is built on first open; until then show nothing. let id = state.id().to_string(); let view = view.clone(); window.defer(cx, move |window, cx| { let _ = view.update(cx, |this, cx| this.select_provider(&id, window, cx)); }); div().into_any_element() } (None, _) => div().p(px(18.)).text_size(px(13.)).text_color(p.ink3).child("No providers.").into_any_element(), }; let refresh = MetalButton::new("refresh-providers") .ghost() .compact() .icon(IconName::RefreshCw) .label(match (checking, checked) { (true, _) => "Refreshing providers".to_string(), (false, Some(checked)) => format!("Checked {}", ago(checked)), (false, None) => "Check now".to_string(), }) .disabled(checking) .tooltip("Refresh provider status") .on_click(|_, _, cx| Catalog::refresh_all(cx)); let providers = Section::new("Providers") .id("providers") .title_action( MetalButton::new("add-provider-header") .ghost() .mini() .icon(Lucide::Plus) .tooltip("Add provider") .on_click(cx.listener(|this, _, window, cx| this.open_wizard(window, cx))), ) .header_action(refresh) .row( div() .flex() .map(|this| if two_panes { this.flex_row().items_start().min_h(px(512.)) } else { this.flex_col() }) .rounded(px(18.)) .raised(p) .child(list) .child(if two_panes { div().w(px(1.)).self_stretch().bg(p.rule) } else { div().h(px(1.)).w_full().bg(p.rule) }) .child(div().flex_1().min_w_0().child(detail)), ) .plain() .render(cx); let health = settings::get(|settings| settings.health_check_secs); let advanced = Section::new("Advanced") .row( SettingRow::new("Health check interval") .id("provider-health-check-interval") .page_width(WIDE_WIDTH) .description("Refresh provider status, versions, and models in the background. Set to 0 to disable.") .reset(reset(&view, |s| s.health_check_secs, |s, secs| s.health_check_secs = secs)) .control(stepper("health-interval", health, 30, (0, 86_400), "seconds", &view, p, |settings, secs| { settings.health_check_secs = secs })), ) .render(cx); vec![providers, div().id("usage-providers").child(self.hubs.clone()).into_any_element(), advanced] } fn render_provider_editor(&self, state: &InstanceState, window: &mut Window, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let Some(form) = self.providers.form.as_ref() else { return div().into_any_element() }; let id = state.id().to_string(); let kind = state.kind(); let config = state.config.clone(); let default_instance = id == kind.key(); let updating = self.providers.updating.contains(&id); // The editor's own width decides when its rows stack. let content = content_width(window, WIDE_WIDTH); let editor_width = if content >= 768. { content - LIST_WIDTH - 33. } else { content - 32. }; let row = |title: &'static str| SettingRow::new(title).page_width(editor_width); let (_, headline) = status_headline(state); // ---- the instance's header ---- let modified = { let mut fresh = ProviderInstance::new(kind, &id); fresh.enabled = config.enabled; fresh.custom_models = config.custom_models.clone(); fresh != config }; let (reset_id, remove_id, update_id) = (id.clone(), id.clone(), id.clone()); let header_action = h_flex() .gap(px(4.)) .when_some(state.version.clone(), |this, version| this.child(code(version, p))) .when(state.update_available(), |this| { let latest = state.latest.clone().unwrap_or_default(); this.child( MetalButton::new("update-provider") .compact() .icon(IconName::ArrowUp) .label(if updating { "Updating".to_string() } else { format!("Install {latest}") }) .disabled(updating) .tooltip("Update now") .on_click(cx.listener(move |this, _, window, cx| this.update_provider(&update_id, window, cx))), ) }) .when(default_instance && modified, |this| { this.child( MetalButton::new("reset-provider") .ghost() .mini() .icon(Lucide::Undo2) .tooltip(format!("Reset {} provider settings", state.label())) .on_click(cx.listener(move |this, _, window, cx| { this.edit_instance(&reset_id, window, cx, |instance| { let mut fresh = ProviderInstance::new(instance.kind, &instance.id); fresh.enabled = instance.enabled; fresh.custom_models = std::mem::take(&mut instance.custom_models); *instance = fresh; }); let id = reset_id.clone(); this.select_provider(&id, window, cx); })), ) }) .when(!default_instance, |this| { this.child( MetalButton::new("remove-provider") .ghost() .mini() .icon(Lucide::Trash) .tooltip(format!("Delete instance {id}")) .on_click(cx.listener(move |this, _, window, cx| this.remove_provider(&remove_id, window, cx))), ) }); // ---- display name and accent ---- let accent = accent_color(&config.accent); let accent_button = div() .id("accent-swatch") .flex() .flex_none() .items_center() .justify_center() .size(px(28.)) .rounded_full() .cursor_pointer() .map(|this| match accent { Some(color) => this.bg(color).shadow(p.raise_sm.clone()), None => this.well(p).child(Icon::new(Lucide::Plus).size(px(12.)).text_color(p.ink3)), }) .tooltip(|window, cx| Tooltip::new("Accent color").build(window, cx)) .on_click(cx.listener(|this, _, _, cx| { if let Some(form) = this.providers.form.as_mut() { form.accent_open = !form.accent_open; } cx.notify(); })); let accent_picker = form.accent_open.then(|| { let swatches = accent_choices(p).into_iter().enumerate().map(|(ix, color)| { let hex = hex_of(color); let chosen = config.accent.eq_ignore_ascii_case(&hex); let set_id = id.clone(); div() .id(("accent-choice", ix)) .size(px(22.)) .rounded_full() .cursor_pointer() .bg(color) .shadow(if chosen { vec![BoxShadow::new(px(0.), px(0.), p.ink).spread_radius(px(2.))] } else { p.raise_sm.clone() }) .on_click(cx.listener(move |this, _, window, cx| { let hex = hex.clone(); this.edit_instance(&set_id, window, cx, |instance| instance.accent = hex.clone()); if let Some(form) = this.providers.form.as_ref() { form.accent.update(cx, |input, cx| input.set_value(hex, window, cx)); } })) }); let clear_id = id.clone(); h_flex() .gap(px(8.)) .flex_wrap() .children(swatches) .child(text_field(&form.accent, 96., window, cx)) .child(MetalButton::new("clear-accent").ghost().compact().label("Clear color").on_click(cx.listener( move |this, _, window, cx| { this.edit_instance(&clear_id, window, cx, |instance| instance.accent.clear()); if let Some(form) = this.providers.form.as_ref() { form.accent.update(cx, |input, cx| input.set_value("", window, cx)); } }, ))) }); let display = row("Display name") .status(match (&state.account, kind) { (Some(account), Provider::Claude) => match &state.plan { Some(plan) => format!("Authenticated as {account} · {plan}"), None => format!("Authenticated as {account}"), }, _ => headline.clone(), }) .control(h_flex().gap(px(8.)).child(accent_button).child(text_field(&form.name, 224., window, cx))) .when_some(accent_picker, |row, picker| row.below(picker)); let identity = Section::new(state.label()) .icon(instance_icon(kind, &config.accent, 16., p)) .header_action(header_action) .row(display) .render(cx); // ---- setup ---- let account = match (kind, &state.account, &state.status) { (_, _, ProbeStatus::Disabled) => "Turn the provider on to check its account.".to_string(), (Provider::Claude, Some(account), _) => format!("Signed in as {account}"), (Provider::Claude, None, _) => "Not signed in. Run claude in a terminal and log in, then refresh.".into(), (Provider::Pi, _, _) => "Pi signs in to model providers itself: run pi in a terminal and use /login.".into(), (Provider::Jcode, _, _) => "jcode signs in itself: run jcode login in a terminal.".into(), }; let setup = Section::new("Setup").row(row("Account").description(account)).render(cx); // ---- runtime ---- let found = claude::find_binary(&config); let binary_status = binary_status(found.as_deref(), &config); let mut runtime = Section::new("Runtime").row( row("Binary path") .description(match kind { Provider::Claude => "Path to the Claude binary used by this instance.", Provider::Pi => "Path to the Pi coding agent binary.", Provider::Jcode => "Path to the jcode binary used by this instance.", }) .status(binary_status) .control(text_field(&form.binary, 224., window, cx)), ); runtime = runtime.row( row(home_title(kind)) .description(match kind { Provider::Claude => "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", Provider::Pi => "Custom Pi agent directory, so this instance keeps its own logins, settings and sessions.", Provider::Jcode => "Custom jcode home, so this instance keeps its own logins and sessions.", }) .control(text_field(&form.home, 224., window, cx)), ); if kind == Provider::Claude { runtime = runtime.row( row("Auto-compact after") .description("Compact after 100,000 to 1,000,000 tokens. Leave empty to use Claude's default.") .when(form.auto_compact_error, |row| row.status("Enter a number from 100,000 to 1,000,000.")) .control(text_field(&form.auto_compact, 224., window, cx)), ); } runtime = runtime.row( row("Launch arguments") .description(match kind { Provider::Claude => "Additional CLI arguments passed on session start.", Provider::Pi => "Additional CLI arguments passed to pi --mode rpc on session start.", Provider::Jcode => "Additional CLI arguments passed to jcode ahead of api --stdio on session start.", }) .control(text_field(&form.args, 224., window, cx)), ); let runtime = runtime.render(cx); // ---- environment ---- let mut environment = Section::new("Environment").row( row("Variables").description("API keys, base URLs, and other per-instance CLI settings.").control( MetalButton::new("add-env") .compact() .icon(Lucide::Plus) .label("Add variable") .on_click(cx.listener(|this, _, window, cx| this.add_env_var(window, cx))), ), ); for (ix, field) in form.env.iter().enumerate() { let sensitive = field.sensitive; environment = environment.row( h_flex() .px(px(16.)) .py(px(8.)) .gap(px(8.)) .child(text_field(&field.key, 180., window, cx)) .child(div().text_color(p.ink3).child("=")) .child(div().flex_1().min_w_0().child(text_field(&field.value, 0., window, cx).w_full())) .child( MetalButton::new(("env-sensitive", ix)) .ghost() .mini() .icon(if sensitive { Lucide::Lock } else { Lucide::LockOpen }) .tooltip(if sensitive { "Sensitive, stored separately" } else { "Plain text" }) .on_click(cx.listener(move |this, _, window, cx| this.toggle_sensitive(ix, window, cx))), ) .child( MetalButton::new(("remove-env", ix)) .ghost() .mini() .icon(Lucide::Trash) .tooltip("Remove") .on_click(cx.listener(move |this, _, window, cx| this.remove_env_var(ix, window, cx))), ), ); } let environment = v_flex() .gap(px(8.)) .child(environment.render(cx)) .child(note("Sensitive values are stored separately and never returned to the app.", p)) .into_any_element(); let models = self.render_models(state, window, cx); v_flex() .p(px(16.)) .gap(px(24.)) .child(identity) .child(setup) .child(runtime) .child(environment) .child(models) .into_any_element() } fn render_models(&self, state: &InstanceState, window: &mut Window, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let Some(form) = self.providers.form.as_ref() else { return div().into_any_element() }; let models: Vec = state.models.clone(); let keys: Vec = models.iter().map(ModelEntry::key).collect(); let favorite_keys: Vec = settings::get(|settings| settings.favorites.clone()); let is_favorite = |model: &ModelEntry| favorite_keys.contains(&model.key()); let hidden: Vec = settings::get(|settings| settings.hidden_models.clone()); let filter = form.model_filter.read(cx).value().trim().to_lowercase(); let favorites = models.iter().filter(|model| is_favorite(model)).count(); let hidden_count = models.iter().filter(|model| hidden.contains(&model.key())).count(); let shown = |model: &ModelEntry| { filter.is_empty() || model.name.to_lowercase().contains(&filter) || model.id.to_lowercase().contains(&filter) }; let model_row = |model: &ModelEntry, ix: usize, cx: &mut Context| -> AnyElement { let key = model.key(); let custom = state.is_custom(model); let favorite = is_favorite(model); let is_hidden = hidden.contains(&key); let mut capabilities = Vec::new(); if model.fast_mode { capabilities.push("Fast mode"); } if !model.efforts.is_empty() { capabilities.push("Thinking"); } if model.legacy { capabilities.push("Legacy"); } let star_model = model.clone(); let (up_keys, down_keys, hide_key) = (keys.clone(), keys.clone(), key.clone()); let remove_slug = model.id.clone(); h_flex() .h(px(32.)) .px(px(12.)) .gap(px(8.)) .rounded(px(10.)) .hover(|style| style.bg(p.row_hover)) .child( MetalButton::new(SharedString::from(format!("favorite-{key}"))) .ghost() .mini() .icon(Icon::new(Lucide::Star).text_color(if favorite { p.amber } else { p.ink3 })) .tooltip(if favorite { "Remove from favorites" } else { "Add to favorites" }) .on_click(move |_, _, cx| Catalog::toggle_favorite(&star_model, cx)), ) .child( h_flex() .flex_1() .min_w_0() .gap(px(6.)) .child(div().truncate().text_size(px(13.)).text_color(if is_hidden { p.ink3 } else { p.ink }).child(model.name.clone())) .when(model.name != model.id, |this| this.child(code(model.id.clone(), p).max_w(px(160.)).truncate())) .when(custom, |this| this.child(metal::tag("custom", p))), ) .child(div().text_size(px(11.5)).text_color(p.ink3).child(capabilities.join(" · "))) .child( MetalButton::new(SharedString::from(format!("model-up-{key}"))) .ghost() .mini() .icon(IconName::ArrowUp) .tooltip("Move up") .disabled(ix == 0) .on_click(cx.listener(move |this, _, window, cx| this.move_model(up_keys.clone(), ix, -1, window, cx))), ) .child( MetalButton::new(SharedString::from(format!("model-down-{key}"))) .ghost() .mini() .icon(IconName::ArrowDown) .tooltip("Move down") .disabled(ix + 1 >= keys.len()) .on_click(cx.listener(move |this, _, window, cx| this.move_model(down_keys.clone(), ix, 1, window, cx))), ) .when(custom, |this| { let edit_slug = model.id.clone(); this.child( MetalButton::new(SharedString::from(format!("model-edit-{key}"))) .ghost() .mini() .icon(Lucide::Pencil) .tooltip("Edit name") .on_click(cx.listener(move |this, _, window, cx| this.edit_custom_model(edit_slug.clone(), window, cx))), ) .child( MetalButton::new(SharedString::from(format!("model-remove-{key}"))) .ghost() .mini() .icon(Lucide::Trash) .tooltip("Remove custom model") .on_click(cx.listener(move |this, _, window, cx| this.remove_custom_model(remove_slug.clone(), window, cx))), ) }) .child( div() .id(SharedString::from(format!("model-visible-{key}"))) .when(custom, |this| this.opacity(0.5)) .tooltip(move |window, cx| { Tooltip::new(if custom { "Custom models are always shown in the picker" } else if is_hidden { "Hidden from picker" } else { "Shown in picker" }) .build(window, cx) }) .child(metal::toggle(SharedString::from(format!("model-toggle-{key}")), !is_hidden || custom, p, { let view = cx.weak_entity(); move |on, window, cx| { if custom { return; } let key = hide_key.clone(); let _ = view.update(cx, |this, cx| this.set_hidden(vec![key], !on, window, cx)); } })), ) .into_any_element() }; let group = |title: &'static str, rows: Vec| { (!rows.is_empty()).then(|| { v_flex() .gap(px(2.)) .child(div().px(px(12.)).pt(px(8.)).pb(px(4.)).text_size(px(11.5)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child(title)) .children(rows) }) }; let mut favorite_rows = Vec::new(); let mut all_rows = Vec::new(); let mut hidden_rows = Vec::new(); for (ix, model) in models.iter().enumerate() { if !shown(model) { continue; } let mut rows = vec![model_row(model, ix, cx)]; if let Some((slug, input)) = &form.editing_custom && *slug == model.id { rows.push( h_flex() .px(px(12.)) .py(px(6.)) .gap(px(8.)) .child(div().text_size(px(12.5)).text_color(p.ink2).child("Display name")) .child(text_field(input, 0., window, cx).flex_1()) .child( MetalButton::new("custom-name-save") .primary() .compact() .label("Save") .on_click(cx.listener(|this, _, window, cx| this.save_custom_name(window, cx))), ) .child(MetalButton::new("custom-name-cancel").ghost().compact().label("Cancel").on_click(cx.listener( |this, _, _, cx| { if let Some(form) = this.providers.form.as_mut() { form.editing_custom = None; } cx.notify(); }, ))) .into_any_element(), ); } if hidden.contains(&model.key()) && !state.is_custom(model) { hidden_rows.extend(rows); } else if is_favorite(model) { favorite_rows.extend(rows); } else { all_rows.extend(rows); } } let all_keys: Vec = models.iter().filter(|model| !state.is_custom(model)).map(ModelEntry::key).collect(); let any_hidden = hidden_count > 0; let toolbar = h_flex() .px(px(12.)) .py(px(10.)) .gap(px(8.)) .when(models.len() > 8, |this| this.child(search_field(&form.model_filter, 180., cx))) .child( MetalButton::new("models-visibility") .ghost() .compact() .label(if any_hidden { "Enable all" } else { "Disable all" }) .disabled(models.is_empty()) .on_click(cx.listener(move |this, _, window, cx| this.set_hidden(all_keys.clone(), !any_hidden, window, cx))), ) .child( div() .flex_1() .text_size(px(12.)) .text_color(p.ink3) .child(format!("{} models · {favorites} favorites · {hidden_count} hidden", models.len())), ) .child( MetalButton::new("add-custom-model") .compact() .icon(Lucide::Plus) .label("Add custom model") .disabled(form.adding_custom) .on_click(cx.listener(|this, _, window, cx| { if let Some(form) = this.providers.form.as_mut() { form.adding_custom = true; form.custom_error = None; focus_later(&form.custom_slug, window); } cx.notify(); })), ); let adding = form.adding_custom.then(|| { v_flex() .px(px(12.)) .py(px(8.)) .gap(px(6.)) .child( h_flex() .gap(px(8.)) .child(text_field(&form.custom_slug, 0., window, cx).flex_1()) .child( MetalButton::new("custom-model-add") .primary() .compact() .label("Add") .on_click(cx.listener(|this, _, window, cx| this.add_custom_model(window, cx))), ) .child(MetalButton::new("custom-model-cancel").ghost().compact().label("Cancel").on_click(cx.listener( |this, _, _, cx| { if let Some(form) = this.providers.form.as_mut() { form.adding_custom = false; form.custom_error = None; } cx.notify(); }, ))), ) .when_some(form.custom_error.clone(), |this, error| { this.child(div().text_size(px(12.)).text_color(p.red).child(error)) }) }); let body = v_flex() .pb(px(8.)) .child(toolbar) .children(adding) .children(group("Favorites", favorite_rows)) .children(group("All", all_rows)) .children(group("Hidden from picker", hidden_rows)) .when(models.is_empty(), |this| { this.child(div().px(px(12.)).py(px(10.)).text_size(px(12.5)).text_color(p.ink3).child(match &state.status { ProbeStatus::Checking => "Asking for its models…", ProbeStatus::Disabled => "Turn the provider on to list its models.", _ => "No models reported.", })) }); v_flex() .gap(px(8.)) .child(Section::new("Models").row(body).render(cx)) .child(note( "Favorites, visibility, and ordering are saved on this device. Custom models are saved with the instance.", p, )) .into_any_element() } // ---- the Add provider wizard --------------------------------------- fn open_wizard(&mut self, window: &mut Window, cx: &mut Context) { let input = |placeholder: &str, window: &mut Window, cx: &mut Context| { let placeholder = placeholder.to_string(); cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)) }; self.providers.wizard = Some(Wizard { step: 0, kind: Provider::Claude, label: input("e.g. Work", window, cx), id: input("claude_work", window, cx), accent: String::new(), binary: input("claude", window, cx), home: input("~/.claude", window, cx), args: input("", window, cx), error: None, }); cx.notify(); } fn wizard_next(&mut self, window: &mut Window, cx: &mut Context) { let Some(wizard) = self.providers.wizard.as_mut() else { return }; match wizard.step { 0 => { let kind = wizard.kind; let placeholder = format!("{}_work", kind.key()); wizard.id.update(cx, |input, cx| input.set_placeholder(placeholder, window, cx)); wizard.binary.update(cx, |input, cx| input.set_placeholder(kind.key(), window, cx)); wizard.home.update(cx, |input, cx| input.set_placeholder(home_placeholder(kind), window, cx)); wizard.step = 1; } 1 => { let id = wizard.id.read(cx).value().trim().to_string(); let label = wizard.label.read(cx).value().trim().to_string(); let id = if id.is_empty() && !label.is_empty() { format!("{}_{}", wizard.kind.key(), label.to_lowercase().replace(|c: char| !c.is_ascii_alphanumeric(), "_")) } else { id }; wizard.error = if id.is_empty() { Some("Enter an instance ID.".into()) } else if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { Some("Instance IDs use letters, digits, '-', or '_'.".into()) } else if settings::get(|settings| settings.instance(&id).is_some()) { Some(format!("An instance called {id} already exists.").into()) } else { None }; if wizard.error.is_none() { wizard.id.update(cx, |input, cx| input.set_value(id, window, cx)); wizard.step = 2; } } _ => self.wizard_finish(window, cx), } cx.notify(); } fn wizard_finish(&mut self, window: &mut Window, cx: &mut Context) { let Some(wizard) = self.providers.wizard.take() else { return }; let value = |input: &Entity| input.read(cx).value().trim().to_string(); let id = value(&wizard.id); let mut instance = ProviderInstance::new(wizard.kind, &id); instance.name = value(&wizard.label); instance.accent = wizard.accent.clone(); instance.binary = value(&wizard.binary); instance.home = value(&wizard.home); instance.args = value(&wizard.args); settings::update(|settings| settings.providers.push(instance)); Catalog::sync_instances(cx); window.push_notification(Notification::success("Provider instance added"), cx); self.select_provider(&id, window, cx); self.changed(window, cx); } /// The wizard, floating over the settings. pub(super) fn render_add_provider(&self, window: &mut Window, cx: &mut Context) -> Option { let wizard = self.providers.wizard.as_ref()?; let p = metal::palette(cx); let steps = ["Provider", "Identity", "Config"]; let field = |title: &'static str, hint: &'static str, input: &Entity, window: &mut Window, cx: &mut Context| { v_flex() .gap(px(6.)) .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title)) .child(text_field(input, 0., window, cx).w_full()) .when(!hint.is_empty(), |this| this.child(div().text_size(px(12.)).text_color(p.ink3).child(hint))) }; let body: AnyElement = match wizard.step { 0 => div() .grid() .grid_cols(2) .gap(px(8.)) .children(Provider::ALL.into_iter().map(|kind| { let selected = kind == wizard.kind; h_flex() .id(SharedString::from(format!("wizard-kind-{}", kind.key()))) .h(px(56.)) .px(px(14.)) .gap(px(10.)) .rounded(px(14.)) .cursor_pointer() .map(|this| if selected { this.raised(p) } else { this.well(p) }) .on_click(cx.listener(move |this, _, _, cx| { if let Some(wizard) = this.providers.wizard.as_mut() { wizard.kind = kind; } cx.notify(); })) .child(super::radio(selected, p)) .child(pickers::provider_icon(kind, px(18.), p)) .child(div().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(kind.label())) })) .into_any_element(), 1 => { let swatches = accent_choices(p).into_iter().enumerate().map(|(ix, color)| { let hex = hex_of(color); let chosen = wizard.accent == hex; div() .id(("wizard-accent", ix)) .size(px(22.)) .rounded_full() .cursor_pointer() .bg(color) .shadow(if chosen { vec![BoxShadow::new(px(0.), px(0.), p.ink).spread_radius(px(2.))] } else { p.raise_sm.clone() }) .on_click(cx.listener(move |this, _, _, cx| { if let Some(wizard) = this.providers.wizard.as_mut() { wizard.accent = if wizard.accent == hex { String::new() } else { hex.clone() }; } cx.notify(); })) }).collect::>(); v_flex() .gap(px(14.)) .child(field("Label", "Shown in the provider list.", &wizard.label, window, cx)) .child(field("Instance ID", "Letters, digits, '-', or '_'.", &wizard.id, window, cx)) .child( v_flex() .gap(px(6.)) .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child("Accent color")) .child(h_flex().gap(px(8.)).children(swatches)) .child(div().text_size(px(12.)).text_color(p.ink3).child("Optional marker shown in the picker.")), ) .when_some(wizard.error.clone(), |this, error| { this.child( div().px(px(12.)).py(px(8.)).rounded(px(12.)).bg(p.red.opacity(0.08)).text_size(px(12.5)).text_color(p.red).child(error), ) }) .into_any_element() } _ => v_flex() .gap(px(14.)) .child(field("Binary path", "Leave empty to look it up on PATH.", &wizard.binary, window, cx)) .child(field(home_title(wizard.kind), "Leave empty for the default.", &wizard.home, window, cx)) .child(field("Launch arguments", "Additional CLI arguments passed on session start.", &wizard.args, window, cx)) .into_any_element(), }; let step = wizard.step; let dialog = v_flex() .id("add-provider-dialog") .w(px(520.)) .p(px(20.)) .gap(px(16.)) .rounded(px(22.)) .plate(p) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( v_flex() .gap(px(4.)) .child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("Add provider")) .child(div().text_size(px(12.5)).text_color(p.ink3).child("Add an account or configure a provider on this machine.")), ) .child(h_flex().gap(px(6.)).children(steps.iter().enumerate().map(|(ix, label)| { h_flex() .gap(px(6.)) .child( div() .flex() .items_center() .justify_center() .size(px(18.)) .rounded_full() .text_size(px(10.5)) .font_weight(FontWeight::SEMIBOLD) .map(|this| { if ix <= step { this.bg(metal::vertical(p.primary)).text_color(p.primary_ink) } else { this.well(p).text_color(p.ink3) } }) .child((ix + 1).to_string()), ) .child(div().text_size(px(12.5)).text_color(if ix == step { p.ink } else { p.ink3 }).child(*label)) .when(ix + 1 < steps.len(), |this| this.child(div().w(px(20.)).h(px(1.)).bg(p.rule))) }))) .child(body) .child( h_flex() .gap(px(8.)) .child(div().flex_1()) .child( MetalButton::new("wizard-back") .ghost() .compact() .label(if step == 0 { "Cancel" } else { "Back" }) .on_click(cx.listener(|this, _, _, cx| { match this.providers.wizard.as_mut() { Some(wizard) if wizard.step > 0 => { wizard.step -= 1; wizard.error = None; } _ => this.providers.wizard = None, } cx.notify(); })), ) .child( MetalButton::new("wizard-next") .primary() .compact() .label(if step == 2 { "Add instance" } else { "Next" }) .on_click(cx.listener(|this, _, window, cx| this.wizard_next(window, cx))), ), ); Some( div() .absolute() .inset_0() .flex() .items_center() .justify_center() .bg(black().opacity(if p.dark { 0.4 } else { 0.16 })) .occlude() .on_mouse_down( MouseButton::Left, cx.listener(|this, _, _, cx| { this.providers.wizard = None; cx.notify(); }), ) .child(dialog) .into_any_element(), ) } } /// Where a binary was found, and what overrides the field. fn binary_status(found: Option<&Path>, instance: &ProviderInstance) -> String { let found_text = match found { Some(path) if claude::is_executable(path) => format!("Using {}.", display_path(path)), Some(path) => format!("{} isn't an executable file.", display_path(path)), None => "Not found on PATH or in the usual install folders.".to_string(), }; let var = claude::binary_var(instance.kind); if instance.id == instance.kind.key() && std::env::var_os(var).is_some() { format!("{found_text} Set by {var}, which wins over this field.") } else { found_text } }