//! 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<InputState>,
value: Entity<InputState>,
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<InputState>,
binary: Entity<InputState>,
home: Entity<InputState>,
auto_compact: Entity<InputState>,
args: Entity<InputState>,
accent: Entity<InputState>,
env: Vec<EnvField>,
model_filter: Entity<InputState>,
custom_slug: Entity<InputState>,
adding_custom: bool,
custom_error: Option<SharedString>,
/// The custom model whose display name is being edited, and the field.
editing_custom: Option<(String, Entity<InputState>)>,
accent_open: bool,
auto_compact_error: bool,
_subscriptions: Vec<Subscription>,
}
/// The "Add provider" wizard.
struct Wizard {
step: usize,
kind: Provider,
label: Entity<InputState>,
id: Entity<InputState>,
accent: String,
binary: Entity<InputState>,
home: Entity<InputState>,
args: Entity<InputState>,
error: Option<SharedString>,
}
#[derive(Default)]
pub(super) struct ProvidersState {
form: Option<ProviderForm>,
wizard: Option<Wizard>,
/// Instances an update is running for.
updating: Vec<String>,
}
/// 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<Hsla> {
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<Hsla> {
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<Self>) -> Entity<InputState> {
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<Self>) {
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<EnvField> = 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<Self>) {
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<Self>) {
let Some(form) = self.providers.form.as_mut() else { return };
let value = |input: &Entity<InputState>, 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<Self>, 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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<String>, ix: usize, by: isize, window: &mut Window, cx: &mut Context<Self>) {
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<String>, hidden: bool, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let catalog = Catalog::global(cx);
let instances: Vec<InstanceState> = 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, cx, |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<Self>) -> 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<Self>) -> AnyElement {
let p = metal::palette(cx);
let Some(form) = self.providers.form.as_ref() else { return div().into_any_element() };
let models: Vec<ModelEntry> = state.models.clone();
let keys: Vec<String> = models.iter().map(ModelEntry::key).collect();
let favorite_keys: Vec<String> = settings::get(|settings| settings.favorites.clone());
let is_favorite = |model: &ModelEntry| favorite_keys.contains(&model.key());
let hidden: Vec<String> = 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<Self>| -> 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<AnyElement>| {
(!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<String> = 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<Self>) {
let input = |placeholder: &str, window: &mut Window, cx: &mut Context<Self>| {
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<Self>) {
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<Self>) {
let Some(wizard) = self.providers.wizard.take() else { return };
let value = |input: &Entity<InputState>| 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<Self>) -> Option<AnyElement> {
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<InputState>, window: &mut Window, cx: &mut Context<Self>| {
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::<Vec<_>>();
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
}
}