//! The settings view, laid out like T3 Code's: the pages listed where the
//! thread list was, and the open page on the sheet. Every change is saved at
//! once and handed to the open conversations.
use std::{
path::Path,
process::{Command, Stdio},
rc::Rc,
time::SystemTime,
};
use gpui_kit::component::{
Icon, IconName, Sizable as _, WindowExt as _, h_flex,
input::{Input, InputEvent, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
notification::Notification,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::CloseSettings;
use crate::claude::{self, Effort, PermissionMode};
use crate::models::{self, Catalog, InstanceState, ProbeStatus, Provider, split_key};
use crate::remote::{self, DEFAULT_PORT, client::ConnectionStatus};
use crate::session::{Reasoning, Session};
use crate::settings::{
self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, RemoteHost, Settings, Streaming,
Submodules, TimeFormat, WorkspaceMode,
};
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;
use crate::ui::themes;
use crate::ui::usage::{HubSettings, UsageView};
use crate::usage::UsageSources;
use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
const PAGE_WIDTH: f32 = 760.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
General,
Behavior,
Appearance,
Themes,
Providers,
Usage,
Connections,
Keybindings,
Archive,
About,
}
impl Page {
const ALL: [Page; 10] = [
Page::General,
Page::Behavior,
Page::Appearance,
Page::Themes,
Page::Providers,
Page::Usage,
Page::Connections,
Page::Keybindings,
Page::Archive,
Page::About,
];
fn label(self) -> &'static str {
match self {
Page::General => "General",
Page::Behavior => "Behavior",
Page::Appearance => "Appearance",
Page::Themes => "Themes",
Page::Providers => "Providers",
Page::Usage => "Usage",
Page::Connections => "Connections",
Page::Keybindings => "Keybindings",
Page::Archive => "Archive",
Page::About => "About",
}
}
fn icon(self) -> Icon {
match self {
Page::General => Icon::new(Lucide::SlidersHorizontal),
Page::Behavior => Icon::new(Lucide::Bell),
Page::Appearance => Icon::new(IconName::Palette),
Page::Themes => Icon::new(Lucide::SwatchBook),
Page::Providers => Icon::new(Lucide::Plug),
Page::Usage => Icon::new(Lucide::Gauge),
Page::Connections => Icon::new(Lucide::Network),
Page::Keybindings => Icon::new(Lucide::Keyboard),
Page::Archive => Icon::new(Lucide::Archive),
Page::About => Icon::new(IconName::Info),
}
}
}
pub enum SettingsEvent {
Close,
}
/// The fields of the provider instance open on the Providers page.
struct ProviderForm {
id: String,
name: Entity<InputState>,
binary: Entity<InputState>,
home: Entity<InputState>,
args: Entity<InputState>,
env: Vec<(Entity<InputState>, Entity<InputState>)>,
_subscriptions: Vec<Subscription>,
}
pub struct SettingsView {
workspace: WeakEntity<Workspace>,
page: Page,
add_project_dir: Entity<InputState>,
provider_form: Option<ProviderForm>,
usage: Entity<UsageView>,
hubs: Entity<HubSettings>,
remote_port: Entity<InputState>,
/// The "Add connection" form: name, address or pairing code, key.
connect_name: Entity<InputState>,
connect_address: Entity<InputState>,
connect_key: Entity<InputState>,
show_key: bool,
/// Instances an update is running for.
updating: Vec<String>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<SettingsEvent> for SettingsView {}
const WORKSPACES: &[(WorkspaceMode, &str)] = &[(WorkspaceMode::Local, "Local"), (WorkspaceMode::Worktree, "New worktree")];
const SUBMODULES: &[(Submodules, &str)] =
&[(Submodules::Skip, "Skip"), (Submodules::TopLevel, "Top level"), (Submodules::Recursive, "Recursive")];
const PROJECT_ORDERS: &[(ProjectOrder, &str)] = &[
(ProjectOrder::LastMessage, "Last user message"),
(ProjectOrder::Name, "Name"),
(ProjectOrder::Added, "Date added"),
];
const QUIT_SHORTCUTS: &[(QuitShortcut, &str)] = &[(QuitShortcut::Press, "Press"), (QuitShortcut::Hold, "Hold")];
const NOTIFICATIONS: &[((bool, bool), &str)] =
&[((false, false), "Off"), ((true, false), "Notifications only"), ((true, true), "Notifications and sound")];
const TIME_FORMATS: &[(TimeFormat, &str)] =
&[(TimeFormat::System, "System default"), (TimeFormat::Hour12, "12-hour"), (TimeFormat::Hour24, "24-hour")];
const STREAMING: &[(Streaming, &str)] =
&[(Streaming::Tokens, "Stream tokens"), (Streaming::Paragraphs, "Show finished paragraphs")];
const DIFF_STATES: &[(bool, &str)] = &[(false, "Expanded"), (true, "Collapsed")];
const DIFF_LAYOUTS: &[(bool, &str)] = &[(false, "Stacked"), (true, "Side by side")];
const SEND_KEYS: &[(bool, &str)] = &[(false, "Enter"), (true, "Ctrl+Enter")];
const FOLLOW_UPS: &[(FollowUp, &str)] = &[(FollowUp::Steer, "Steer"), (FollowUp::Queue, "Queue")];
const BACKGROUND: &[(BackgroundActivity, &str)] = &[
(BackgroundActivity::Always, "Always on"),
(BackgroundActivity::Balanced, "Balanced"),
(BackgroundActivity::Minimal, "Minimal"),
];
impl SettingsView {
pub fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let saved = settings::load();
let add_project_dir = cx.new(|cx| {
InputState::new(window, cx).placeholder("~/").default_value(saved.add_project_dir.clone())
});
let remote_port = cx.new(|cx| {
InputState::new(window, cx).placeholder(DEFAULT_PORT.to_string()).default_value(saved.remote_port.to_string())
});
let connect_name = cx.new(|cx| InputState::new(window, cx).placeholder("Studio desktop"));
let connect_address = cx.new(|cx| InputState::new(window, cx).placeholder("192.168.2.10 or a pairing code"));
let connect_key = cx.new(|cx| InputState::new(window, cx).placeholder("Key").masked(true));
let port_subscription = cx.subscribe_in(&remote_port, window, |this, input, event: &InputEvent, window, cx| {
if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
let port = input.read(cx).value().trim().parse::<u16>().ok().filter(|port| *port > 0).unwrap_or(DEFAULT_PORT);
if settings::get(|settings| settings.remote_port != port) {
settings::update(|settings| settings.remote_port = port);
this.changed(window, cx);
}
}
});
let subscriptions = vec![port_subscription, cx.subscribe_in(
&add_project_dir,
window,
|this, input, event: &InputEvent, window, cx| {
if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
let value = input.read(cx).value().trim().to_string();
if settings::get(|settings| settings.add_project_dir != value) {
settings::update(|settings| settings.add_project_dir = value);
this.changed(window, cx);
}
}
},
)];
Self {
workspace,
page: Page::General,
add_project_dir,
provider_form: None,
usage: cx.new(|cx| UsageView::new(window, cx)),
hubs: cx.new(|cx| HubSettings::new(window, cx)),
remote_port,
connect_name,
connect_address,
connect_key,
show_key: false,
updating: Vec::new(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
/// Readies the view for showing: the fields reflect the saved settings,
/// and Esc closes it.
pub fn opened(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.reload_inputs(window, cx);
self.focus_handle.focus(window, cx);
cx.notify();
}
fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let (dir, port) = settings::get(|settings| (settings.add_project_dir.clone(), settings.remote_port));
self.add_project_dir.update(cx, |input, cx| input.set_value(dir, window, cx));
self.remote_port.update(cx, |input, cx| input.set_value(port.to_string(), window, cx));
let selected = self.provider_form.as_ref().map(|form| form.id.clone());
self.provider_form = None;
if let Some(id) = selected {
self.select_provider(&id, window, cx);
}
}
/// Repaints, and lets the open conversations pick up the change.
fn changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let _ = self.workspace.update(cx, |workspace, cx| workspace.settings_changed(window, cx));
cx.notify();
}
fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context<Self>) {
settings::restore_defaults();
metal::sync_appearance(window, cx);
self.reload_inputs(window, cx);
self.changed(window, cx);
}
fn close(&mut self, cx: &mut Context<Self>) {
cx.emit(SettingsEvent::Close);
}
// ---- frame -------------------------------------------------------------
fn render_nav(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let settled = self
.workspace
.upgrade()
.map(|workspace| workspace.read(cx).settled_threads(cx).len())
.unwrap_or(0);
let updates = Catalog::global(cx).instances.iter().filter(|state| state.update_available()).count();
v_flex()
.w(px(SIDEBAR_WIDTH))
.h_full()
.flex_none()
.child(
h_flex()
.flex_none()
.h(px(52.))
.px(px(16.))
.gap(px(8.))
.child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
.child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
.child(metal::tag("Settings", p)),
)
.child(
v_flex()
.flex_1()
.min_h_0()
.px(px(8.))
.pt(px(12.))
.gap(px(3.))
.children(Page::ALL.into_iter().map(|page| {
let selected = page == self.page;
let count = match page {
Page::Archive if settled > 0 => Some(settled.to_string()),
Page::Providers if updates > 0 => Some(format!("{updates} update{}", if updates == 1 { "" } else { "s" })),
_ => None,
};
h_flex()
.id(SharedString::from(format!("settings-page-{}", page.label())))
.h(px(38.))
.px(px(12.))
.gap(px(10.))
.rounded(px(13.))
.cursor_pointer()
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.map(|this| {
if selected {
this.raised(p).text_color(p.ink)
} else {
this.text_color(p.ink2).hover(|style| style.bg(p.row_hover).text_color(p.ink))
}
})
.on_click(cx.listener(move |this, _, _, cx| {
this.page = page;
if page == Page::Usage {
this.refresh_usage(cx);
}
cx.notify();
}))
.child(page.icon().size(px(15.)).text_color(if selected { p.ink } else { p.ink2 }))
.child(div().flex_1().child(page.label()))
.when_some(count, |this, count| {
this.child(
div()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(count),
)
})
})),
)
.child(
h_flex()
.flex_none()
.h(px(48.))
.px(px(12.))
.gap(px(8.))
.child(
MetalButton::new("settings-back")
.ghost()
.compact()
.icon(IconName::ArrowLeft)
.label("Back")
.tooltip("Back to threads (Esc)")
.on_click(cx.listener(|this, _, _, cx| this.close(cx))),
)
.child(div().flex_1())
.child(metal::engraved(format!("v{}", env!("CARGO_PKG_VERSION")), p)),
)
}
fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let resettable = matches!(self.page, Page::General | Page::Behavior | Page::Appearance | Page::Themes);
v_flex()
.flex_none()
.child(
h_flex()
.h(px(52.))
.pl(px(20.))
.pr(px(12.))
.gap(px(10.))
.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Settings"))
.child(div().text_size(px(14.)).text_color(p.ink3).child("/"))
.child(
div()
.text_size(px(14.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink)
.child(self.page.label()),
)
.child(div().flex_1())
.when(resettable, |this| {
this.child(
MetalButton::new("restore-defaults")
.ghost()
.compact()
.icon(Lucide::RotateCcw)
.label("Restore defaults")
.tooltip("Reset every setting except favorites, providers and usage hubs")
.on_click(cx.listener(|this, _, window, cx| this.restore_defaults(window, cx))),
)
})
.child(
MetalButton::new("close-settings")
.ghost()
.mini()
.icon(IconName::Close)
.tooltip("Close (Esc)")
.on_click(cx.listener(|this, _, _, cx| this.close(cx))),
),
)
.child(metal::rule(p))
}
fn render_page(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
match self.page {
Page::General => self.render_general(window, cx),
Page::Behavior => self.render_behavior(cx),
Page::Appearance => self.render_appearance(cx),
Page::Themes => self.render_themes(cx),
Page::Providers => self.render_providers(window, cx),
Page::Usage => vec![self.usage.clone().into_any_element()],
Page::Connections => self.render_connections(window, cx),
Page::Keybindings => self.render_keybindings(cx),
Page::Archive => self.render_archive(cx),
Page::About => self.render_about(cx),
}
}
// ---- pages -------------------------------------------------------------
fn render_general(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let saved = settings::load();
let view = cx.weak_entity();
let model_pick = {
let view = view.clone();
move |key: Option<String>, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| settings.default_model = key)
}
};
let reasoning_pick = {
let view = view.clone();
move |reasoning: Option<Reasoning>, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| {
settings.default_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
})
}
};
let new_threads = section(
"New threads",
None,
vec![
row(
"Model",
"Default model for new threads. Following the open thread keeps whatever you picked last.",
h_flex()
.gap(px(6.))
.child(model_control("default-model", saved.default_model.clone(), "Follow open thread", model_pick, cx))
.child(reasoning_control(
"default-reasoning",
saved.default_reasoning.as_deref().and_then(Reasoning::from_key),
"Follow open thread",
reasoning_pick,
)),
p,
),
row(
"Permissions",
"Default permissions for new threads. Pi threads always run with full access.",
self.access_control(&saved, cx),
p,
),
row(
"Workspace",
"Where new threads start: the project folder, or a git worktree and branch of their own. The start screen can switch it per thread.",
choice("workspace", saved.workspace, WORKSPACES, &view, |settings, mode| settings.workspace = mode),
p,
),
row(
"Submodules",
"How new worktrees populate git submodules.",
choice("submodules", saved.submodules, SUBMODULES, &view, |settings, mode| settings.submodules = mode),
p,
),
],
p,
);
let text_pick = {
let view = view.clone();
move |key: Option<String>, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| settings.text_model = key.unwrap_or_default())
}
};
let text_reasoning_pick = {
let view = view.clone();
move |reasoning: Option<Reasoning>, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| {
settings.text_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
})
}
};
let text_model = Some(saved.text_model.clone()).filter(|key| !key.is_empty());
let text_generation = section(
"Text generation",
None,
vec![row(
"Text generation model",
"Writes thread titles from the first message. Off keeps the message's first line.",
h_flex()
.gap(px(6.))
.child(model_control("text-model", text_model.clone(), "Off", text_pick, cx))
.when(text_model.is_some(), |this| {
this.child(reasoning_control(
"text-reasoning",
saved.text_reasoning.as_deref().and_then(Reasoning::from_key),
"Default",
text_reasoning_pick,
))
}),
p,
)],
p,
);
let projects = section(
"Projects & threads",
None,
vec![
row(
"Start from origin",
"Creates worktrees from the latest matching branch on origin instead of your local branch.",
switch("start-from-origin", saved.start_from_origin, &view, p, |settings, on| {
settings.start_from_origin = on
}),
p,
),
row(
"Add project starts in",
"Leave empty to use \u{201c}~/\u{201d} when the Add project browser opens.",
text_field(&self.add_project_dir, 260., window, cx),
p,
),
],
p,
);
let organization = section(
"Organization",
None,
vec![
row(
"Project grouping",
"Group sidebar threads under their project, worktree threads included.",
switch("group-by-project", saved.group_by_project, &view, p, |settings, on| {
settings.group_by_project = on
}),
p,
),
row(
"Project order",
"Order of projects in the sidebar and the project menus.",
choice("project-order", saved.project_order, PROJECT_ORDERS, &view, |settings, order| {
settings.project_order = order
}),
p,
),
row(
"Auto-resume limited threads",
"Continue a thread a usage limit stopped once the limit resets. Each thread can cancel its scheduled continuation.",
switch("auto-resume", saved.auto_resume_limited, &view, p, |settings, on| {
settings.auto_resume_limited = on
}),
p,
),
row(
"Snooze limited threads",
"Snooze threads a usage limit stopped until the limit resets. Combine with auto-resume to continue when they wake.",
switch("snooze-limited", saved.snooze_limited, &view, p, |settings, on| settings.snooze_limited = on),
p,
),
row(
"Working section (beta)",
"Fold working threads into a Working section. They return to the top of the list when they need you.",
switch("working-section", saved.working_section, &view, p, |settings, on| {
settings.working_section = on
}),
p,
),
row(
"Auto-settle merged threads",
"Settle a worktree thread when its pull request merges (checked with GitHub's gh). Closed pull requests settle either way.",
switch("auto-settle-merged", saved.auto_settle_merged, &view, p, |settings, on| {
settings.auto_settle_merged = on
}),
p,
),
row(
"Auto-settle inactive threads",
"Threads with no activity for this long settle on their own. Pinned threads stay.",
switch("auto-settle-inactive", saved.auto_settle_inactive, &view, p, |settings, on| {
settings.auto_settle_inactive = on
}),
p,
),
row(
"Days of inactivity before auto-settle",
"Any new message un-settles a thread.",
div().when(!saved.auto_settle_inactive, |this| this.opacity(0.45)).child(stepper(
"inactive-days",
u64::from(saved.inactive_days),
1,
(1, 90),
"days",
&view,
p,
|settings, days| settings.inactive_days = days as u32,
)),
p,
),
],
p,
);
let confirmations = section(
"Confirmations",
None,
vec![
row(
"Unpin confirmation",
"Ask before unpinning a thread.",
switch("confirm-unpin", saved.confirm_unpin, &view, p, |settings, on| settings.confirm_unpin = on),
p,
),
row(
"Settle confirmation",
"Require a second click on a thread card's settle button before the thread is settled.",
switch("confirm-settle", saved.confirm_settle, &view, p, |settings, on| settings.confirm_settle = on),
p,
),
row(
"Delete confirmation",
"Ask before deleting a thread and its chat history.",
switch("confirm-delete", saved.confirm_delete, &view, p, |settings, on| settings.confirm_delete = on),
p,
),
row(
"Quit shortcut",
"Press quits on Ctrl+Q. Hold waits for the keys to be held, and also quits on two quick presses.",
choice("quit-shortcut", saved.quit_shortcut, QUIT_SHORTCUTS, &view, |settings, shortcut| {
settings.quit_shortcut = shortcut
}),
p,
),
],
p,
);
vec![new_threads, text_generation, projects, organization, confirmations]
}
fn render_behavior(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let saved = settings::load();
let view = cx.weak_entity();
let can_notify = workspace::find_program(&["notify-send"]).is_some();
let notifications = section(
"Notifications",
None,
vec![
row(
"Thread notifications",
if can_notify {
"Desktop alerts when a thread finishes, fails, or needs input or approval while the window is in the background."
} else {
"Needs notify-send (libnotify), which isn't installed."
},
div().when(!can_notify, |this| this.opacity(0.45)).child(choice(
"thread-notifications",
(saved.notify && can_notify, saved.notify_sound),
NOTIFICATIONS,
&view,
move |settings, (notify, sound)| {
settings.notify = notify && can_notify;
settings.notify_sound = sound;
},
)),
p,
),
row(
"In-app notifications",
"Show a toast when another thread finishes, fails, or needs input or approval while the app has focus.",
switch("in-app-notifications", saved.in_app_notifications, &view, p, |settings, on| {
settings.in_app_notifications = on
}),
p,
),
],
p,
);
let conversation = section(
"Conversation",
None,
vec![
row(
"Time format",
"System default follows your locale.",
choice("time-format", saved.time_format, TIME_FORMATS, &view, |settings, format| {
settings.time_format = format
}),
p,
),
row(
"Response streaming",
"Stream every token, or show each paragraph or code block as soon as it's complete.",
choice("streaming", saved.streaming, STREAMING, &view, |settings, streaming| {
settings.streaming = streaming
}),
p,
),
row(
"Fold finished turns",
"Tuck a finished turn's tool calls and interim messages into one \u{201c}Worked for\u{201d} row.",
switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on),
p,
),
row(
"Show costs",
"What each turn and thread cost, in the transcript and the project panel.",
switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on),
p,
),
row(
"Proactive panels",
"Open the diff after a turn that edits at least 3 files or 50 lines.",
switch("proactive-panels", saved.proactive_panels, &view, p, |settings, on| {
settings.proactive_panels = on
}),
p,
),
],
p,
);
let diffs = section(
"Diffs",
None,
vec![
row(
"Hide whitespace changes",
"Whether the diff view ignores whitespace-only edits by default.",
switch("hide-whitespace", saved.hide_whitespace, &view, p, |settings, on| {
settings.hide_whitespace = on
}),
p,
),
row(
"Default diff file state",
"Start with files expanded or collapsed when opening a diff.",
choice("diff-collapsed", saved.diff_collapsed, DIFF_STATES, &view, |settings, collapsed| {
settings.diff_collapsed = collapsed
}),
p,
),
row(
"Diff layout",
"Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.",
choice("diff-layout", saved.diff_side_by_side, DIFF_LAYOUTS, &view, |settings, side_by_side| {
settings.diff_side_by_side = side_by_side
}),
p,
),
],
p,
);
let composer = section(
"Composer",
None,
vec![
row(
"Show skills in slash menu",
"Also list skills in the / command menu. Skills always appear when you type $.",
switch("slash-skills", saved.slash_skills, &view, p, |settings, on| settings.slash_skills = on),
p,
),
row(
"Rich text composer",
"Show formatted Markdown as you type.",
switch("rich-composer", saved.rich_composer, &view, p, |settings, on| settings.rich_composer = on),
p,
),
row(
"Collapse composer on scroll",
"Rest the composer of a conversation on one line while you scroll it. Click it or start typing to expand it again.",
switch("collapse-composer", saved.collapse_composer, &view, p, |settings, on| {
settings.collapse_composer = on
}),
p,
),
row(
"Send shortcut",
"Which keys send a message; the other starts a new line.",
choice("send-shortcut", saved.send_with_ctrl_enter, SEND_KEYS, &view, |settings, ctrl| {
settings.send_with_ctrl_enter = ctrl
}),
p,
),
row(
"Follow-up behavior",
"Steer the running turn with messages sent while the agent works, or queue them for after it. Ctrl+Enter (Ctrl+Shift+Enter when Ctrl+Enter sends) does the opposite for one message.",
choice("follow-up", saved.follow_up, FOLLOW_UPS, &view, |settings, follow_up| {
settings.follow_up = follow_up
}),
p,
),
],
p,
);
let background = section(
"Background",
None,
vec![
row(
"Provider update checks",
"Check the installed agent CLIs for newer versions.",
switch_then("update-checks", saved.update_checks, &view, p, |settings, on| settings.update_checks = on, |cx| {
Catalog::refresh_all(cx)
}),
p,
),
row(
"Continue threads after restarts",
"Automatically resume threads an update, crash, or restart interrupted.",
switch("continue-after-restart", saved.continue_after_restart, &view, p, |settings, on| {
settings.continue_after_restart = on
}),
p,
),
row(
"Background activity",
"Balanced pauses health checks and git polling while the window has been away for a while or the machine saves power. Minimal pauses them whenever the window is in the background.",
choice("background-activity", saved.background_activity, BACKGROUND, &view, |settings, activity| {
settings.background_activity = activity
}),
p,
),
row("Open in", "The editor the project panel opens projects in.", self.editor_control(cx), p),
],
p,
);
vec![notifications, conversation, diffs, composer, background]
}
fn access_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
let current = saved.default_access.as_deref().and_then(PermissionMode::from_cli);
let view = cx.weak_entity();
MetalButton::new("default-access")
.compact()
.map(|this| match current {
Some(mode) => this.icon(pickers::access_icon(mode)),
None => this,
})
.label(current.map(PermissionMode::label).unwrap_or("Follow open thread"))
.caret()
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
let pick = |mode: Option<PermissionMode>| {
let view = view.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| {
settings.default_access = mode.map(|mode| mode.as_cli().to_string())
});
}
};
menu = menu
.min_w(px(220.))
.item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
.separator();
for mode in PermissionMode::ALL {
menu = menu.item(
PopupMenuItem::new(mode.label())
.icon(pickers::access_icon(mode))
.checked(current == Some(mode))
.on_click(pick(Some(mode))),
);
}
menu
})
.into_any_element()
}
fn editor_control(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(workspace) = self.workspace.upgrade() else {
return div().into_any_element();
};
let (names, current) = {
let workspace = workspace.read(cx);
(workspace.editor_names(), workspace.editor())
};
let Some(label) = names.get(current).copied() else {
let p = metal::palette(cx);
return div().text_size(px(13.)).text_color(p.ink3).child("No editor found").into_any_element();
};
let weak = self.workspace.clone();
let view = cx.weak_entity();
MetalButton::new("default-editor")
.compact()
.icon(Lucide::Code)
.label(label)
.caret()
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
menu = menu.min_w(px(200.));
for (ix, name) in names.iter().enumerate() {
let weak = weak.clone();
let view = view.clone();
menu = menu.item(PopupMenuItem::new(*name).checked(ix == current).on_click(move |_, _, cx| {
let _ = weak.update(cx, |workspace, cx| workspace.set_editor(ix, cx));
let _ = view.update(cx, |_, cx| cx.notify());
}));
}
menu
})
.into_any_element()
}
fn render_appearance(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let view = cx.weak_entity();
let compact = settings::get(|settings| settings.compact_threads);
let sidebar = section(
"Sidebar",
None,
vec![row(
"Thread cards",
"Comfortable cards show the project, branch and agent. Compact ones fit a thread on one line.",
metal::segmented(
"thread-density",
&[(false, "Comfortable"), (true, "Compact")],
compact,
p,
move |compact, window, cx| edit(&view, window, cx, move |settings| settings.compact_threads = compact),
),
p,
)],
p,
);
vec![sidebar]
}
fn render_themes(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let view = cx.weak_entity();
themes::page(
cx,
Rc::new(move |_, cx| {
let _ = view.update(cx, |_, cx| cx.notify());
}),
)
}
// ---- providers ---------------------------------------------------------
/// Opens an instance's fields on the Providers 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.provider_form = None;
cx.notify();
return;
};
let input = |value: &str, placeholder: &str, window: &mut Window, cx: &mut Context<Self>| {
let (value, placeholder) = (value.to_string(), placeholder.to_string());
cx.new(|cx| InputState::new(window, cx).placeholder(placeholder).default_value(value))
};
let default_home = match instance.kind {
Provider::Claude => "~/.claude",
Provider::Pi => "~/.pi/agent",
};
let name = input(&instance.name, instance.kind.label(), window, cx);
let binary = input(&instance.binary, "Look it up on PATH", window, cx);
let home = input(&instance.home, default_home, window, cx);
let args = input(&instance.args, "", window, cx);
let env: Vec<_> = instance
.env
.iter()
.map(|var| (input(&var.key, "NAME", window, cx), input(&var.value, "value", window, cx)))
.collect();
let mut subscriptions = Vec::new();
for field in [&name, &binary, &home, &args].into_iter().chain(env.iter().flat_map(|(key, value)| [key, value])) {
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);
}
}));
}
self.provider_form =
Some(ProviderForm { id: instance.id, name, binary, home, args, env, _subscriptions: subscriptions });
cx.notify();
}
/// 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.provider_form else { return };
let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
let (name, binary, home, args) = (value(&form.name), value(&form.binary), value(&form.home), value(&form.args));
let env: Vec<EnvVar> = form
.env
.iter()
.map(|(key, val)| EnvVar { key: value(key), value: val.read(cx).value().to_string() })
.collect();
let id = form.id.clone();
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
})
});
if unchanged {
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;
}
});
Catalog::sync_instances(cx);
self.changed(window, cx);
}
fn set_provider_enabled(&mut self, id: &str, enabled: bool, window: &mut Window, cx: &mut Context<Self>) {
settings::update(|settings| {
if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) {
instance.enabled = enabled;
}
});
Catalog::sync_instances(cx);
self.changed(window, cx);
}
fn add_provider(&mut self, kind: Provider, window: &mut Window, cx: &mut Context<Self>) {
let ids: Vec<String> = settings::instances().into_iter().map(|instance| instance.id).collect();
let id = (2..)
.map(|n| format!("{}-{n}", kind.key()))
.find(|id| !ids.contains(id))
.unwrap_or_else(|| kind.key().to_string());
let mut instance = ProviderInstance::new(kind, &id);
let count = ids.iter().filter(|existing| existing.starts_with(kind.key())).count() + 1;
instance.name = format!("{} {count}", kind.label());
settings::update(|settings| settings.providers.push(instance));
Catalog::sync_instances(cx);
self.select_provider(&id, window, 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.provider_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.provider_form.as_ref().map(|form| form.id.clone()) else { return };
settings::update(|settings| {
if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) {
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.provider_form.as_ref().map(|form| form.id.clone()) else { return };
self.save_provider_form(window, cx);
settings::update(|settings| {
if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id)
&& ix < instance.env.len()
{
instance.env.remove(ix);
}
});
Catalog::sync_instances(cx);
self.select_provider(&id, window, cx);
self.changed(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.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.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();
}
fn render_providers(&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
.provider_form
.as_ref()
.map(|form| form.id.clone())
.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();
let header = h_flex()
.px(px(4.))
.gap(px(6.))
.child(section_title("Providers", p))
.child(
MetalButton::new("add-provider-header")
.ghost()
.mini()
.icon(Lucide::Plus)
.tooltip("Add a provider")
.dropdown_menu(add_provider_menu(view.clone())),
)
.child(div().flex_1())
.child(
MetalButton::new("refresh-providers")
.ghost()
.compact()
.icon(IconName::RefreshCw)
.label(match (checking, checked) {
(true, _) => "Checking…".to_string(),
(false, Some(checked)) => format!("Checked {}", ago(checked)),
(false, None) => "Check now".to_string(),
})
.disabled(checking)
.tooltip("Check versions, accounts and models again")
.on_click(|_, _, cx| Catalog::refresh_all(cx)),
);
let list = v_flex()
.w(px(250.))
.flex_none()
.p(px(6.))
.gap(px(2.))
.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 toggle_id = id.clone();
let toggle_view = view.clone();
let select_id = id.clone();
h_flex()
.id(SharedString::from(format!("provider-{id}")))
.items_start()
.px(px(10.))
.py(px(10.))
.gap(px(10.))
.rounded(px(12.))
.cursor_pointer()
.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(div().pt(px(2.)).child(pickers::provider_icon(state.kind(), px(16.), p)))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(2.))
.child(
h_flex()
.gap(px(6.))
.child(
div()
.truncate()
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(state.label()),
)
.when_some(state.version.clone(), |this, version| {
this.child(
div()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(format!("v{version}")),
)
}),
)
.child(
div()
.text_size(px(12.))
.line_height(px(16.))
.text_color(p.ink3)
.child(status_line(state)),
),
)
.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.set_provider_enabled(&id, on, window, cx));
}))
.into_any_element()
}))
.child(
MetalButton::new("add-provider")
.ghost()
.compact()
.icon(Lucide::Plus)
.label("Add provider")
.dropdown_menu(add_provider_menu(view.clone())),
);
let detail = match (selected.as_deref().and_then(|id| catalog.instance(id)).cloned(), &self.provider_form) {
(Some(state), Some(form)) if form.id == state.id() => self.render_provider_detail(&state, form, window, cx),
(Some(state), _) => {
// The form is built on first open; until then show its summary.
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 providers = v_flex()
.gap(px(10.))
.child(header)
.child(
h_flex()
.items_start()
.rounded(px(18.))
.raised(p)
.child(list)
.child(div().w(px(1.)).self_stretch().bg(p.rule))
.child(div().flex_1().min_w_0().child(detail)),
)
.into_any_element();
let health = settings::get(|settings| settings.health_check_secs);
let advanced = section(
"Advanced",
None,
vec![row(
"Health check interval",
"Refresh provider status, versions, models and limits in the background. Set to 0 to turn it off.",
stepper("health-interval", health, 60, (0, 86_400), "seconds", &view, p, |settings, secs| {
settings.health_check_secs = secs
}),
p,
)],
p,
);
vec![providers, self.hubs.clone().into_any_element(), advanced]
}
fn render_provider_detail(
&self,
state: &InstanceState,
form: &ProviderForm,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let p = metal::palette(cx);
let id = state.id().to_string();
let kind = state.kind();
let config = state.config.clone();
let found = claude::find_binary(&config);
let removable = id != kind.key();
let updating = self.updating.contains(&id);
let field_row = |title: &'static str, description: String, input: &Entity<InputState>, window: &mut Window, cx: &mut Context<Self>| {
row(title, description, text_field(input, 250., window, cx), p)
};
let mut runtime = vec![
field_row(
"Binary path",
binary_status(found.as_deref(), &config),
&form.binary,
window,
cx,
),
field_row(
match kind {
Provider::Claude => "Config folder",
Provider::Pi => "Agent folder",
},
format!(
"Sets {} so this instance keeps its own login, settings and sessions.",
claude::home_var(kind)
),
&form.home,
window,
cx,
),
field_row(
"Launch arguments",
format!(
"Extra arguments passed to {} when a thread starts.",
match kind {
Provider::Claude => "claude",
Provider::Pi => "pi --mode rpc",
}
),
&form.args,
window,
cx,
),
];
if state.update_available() || state.latest.is_some() {
let latest = state.latest.clone().unwrap_or_default();
let available = state.update_available();
let view = cx.weak_entity();
let update_id = id.clone();
runtime.push(row(
"Version",
if available {
format!("{} is out; this is {}.", latest, state.version.clone().unwrap_or_else(|| "unknown".into()))
} else {
format!("Up to date ({latest}).")
},
MetalButton::new("update-provider")
.compact()
.icon(IconName::ArrowUp)
.label(if updating { "Updating…" } else { "Update" })
.disabled(!available || updating)
.on_click(move |_, window, cx| {
let _ = view.update(cx, |this, cx| this.update_provider(&update_id, window, cx));
}),
p,
));
}
let env_rows: Vec<AnyElement> = form
.env
.iter()
.enumerate()
.map(|(ix, (key, value))| {
h_flex()
.px(px(18.))
.py(px(8.))
.gap(px(8.))
.child(text_field(key, 170., window, cx))
.child(div().text_color(p.ink3).child("="))
.child(div().flex_1().min_w_0().child(text_field(value, 220., window, cx).w_full()))
.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))),
)
.into_any_element()
})
.collect();
let mut environment = vec![row(
"Variables",
"API keys, base URLs and other settings for this instance's CLI.",
MetalButton::new("add-env")
.compact()
.icon(Lucide::Plus)
.label("Add variable")
.on_click(cx.listener(|this, _, window, cx| this.add_env_var(window, cx))),
p,
)];
environment.extend(env_rows);
let remove_id = id.clone();
v_flex()
.p(px(14.))
.gap(px(18.))
.child(
h_flex()
.px(px(4.))
.gap(px(10.))
.child(pickers::provider_icon(kind, px(18.), p))
.child(div().flex_1().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child(state.label()))
.child(metal::led(
"provider-led",
match &state.status {
ProbeStatus::Ready => Led::Live,
ProbeStatus::Checking => Led::Waiting,
ProbeStatus::Failed(_) | ProbeStatus::Missing => Led::Failed,
ProbeStatus::Disabled => Led::Off,
},
state.checking,
p,
))
.when(removable, |this| {
this.child(
MetalButton::new("remove-provider")
.ghost()
.compact()
.icon(Lucide::Trash)
.label("Remove")
.on_click(cx.listener(move |this, _, window, cx| {
this.remove_provider(&remove_id, window, cx)
})),
)
}),
)
.child(card(
vec![row(
"Display name",
status_line(state),
text_field(&form.name, 250., window, cx),
p,
)],
p,
))
.child(subsection("Runtime", card(runtime, p), p))
.child(subsection("Environment", card(environment, p), p))
.into_any_element()
}
// ---- usage -------------------------------------------------------------
/// Reads usage from every provider instance's home folder and the usage
/// hubs, and shows each Claude instance's plan limits from its last
/// health check.
fn refresh_usage(&mut self, cx: &mut Context<Self>) {
let catalog = Catalog::global(cx);
let mut sources = UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() };
let mut limits = Vec::new();
for state in &catalog.instances {
let home = match (state.config.home.trim(), state.kind()) {
("", Provider::Claude) => settings::expand_home("~/.claude"),
("", Provider::Pi) => settings::expand_home("~/.pi/agent"),
(home, _) => settings::expand_home(home),
};
let label = match state.kind() {
Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(),
_ => state.label(),
};
match state.kind() {
Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
}
if let Some(usage) = &state.usage {
limits.push((SharedString::from(label), usage.clone()));
}
}
// Instances sharing a home folder share their logs; read them once.
sources.claude_dirs.dedup_by(|a, b| a.1 == b.1);
sources.pi_dirs.dedup_by(|a, b| a.1 == b.1);
self.usage.update(cx, |usage, cx| {
usage.refresh(sources, cx);
usage.set_limits(limits, cx);
});
}
// ---- connections -------------------------------------------------------
/// Saves the "Add connection" form as a new connection.
fn add_connection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
let (address, code_key) = remote::parse_pairing(&value(&self.connect_address));
let key = Some(value(&self.connect_key)).filter(|key| !key.is_empty()).or(code_key);
let (Some(key), false) = (key, address.is_empty()) else {
window.push_notification(Notification::error("Enter the other device's address and key, or its pairing code."), cx);
return;
};
let name = value(&self.connect_name);
let ids: Vec<String> = settings::get(|settings| settings.remotes.iter().map(|host| host.id.clone()).collect());
let id = (1..).map(|n| format!("remote-{n}")).find(|id| !ids.contains(id)).unwrap_or_default();
settings::update(|settings| {
settings.remotes.push(RemoteHost { id, name, address, key, enabled: true });
});
for input in [&self.connect_name, &self.connect_address, &self.connect_key] {
input.update(cx, |input, cx| input.set_value("", window, cx));
}
self.changed(window, cx);
}
fn edit_remote(&mut self, window: &mut Window, cx: &mut Context<Self>, change: impl FnOnce(&mut Vec<RemoteHost>)) {
settings::update(|settings| change(&mut settings.remotes));
self.changed(window, cx);
}
fn render_connections(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let saved = settings::load();
let view = cx.weak_entity();
let overview = self.workspace.upgrade().map(|workspace| workspace.read(cx).remote_overview());
let (serving, error, peers, statuses) = match overview {
Some(overview) => (overview.serving, overview.error, overview.peers, overview.environments),
None => (None, None, Vec::new(), Vec::new()),
};
let addresses: Vec<String> =
remote::local_addresses().into_iter().map(|address| format!("{address}:{}", saved.remote_port)).collect();
let mut this_device = vec![row(
"Allow remote connections",
"Other devices running Vectrum Code can open this device's projects and threads and work in them, with this device's agents and files.",
switch("remote-server", saved.remote_server, &view, p, |settings, on| settings.remote_server = on),
p,
)];
if saved.remote_server {
let (led, status) = match (&error, serving) {
(Some(error), _) => (Led::Failed, error.clone()),
(None, Some(port)) => (
Led::Live,
match addresses.as_slice() {
[] => format!("Listening on port {port}."),
addresses => format!("Listening at {}.", addresses.join(", ")),
},
),
(None, None) => (Led::Waiting, "Starting…".to_string()),
};
this_device.push(row("Status", status, metal::led("remote-server-led", led, serving.is_none() && error.is_none(), p), p));
this_device.push(row(
"Port",
"The port other devices connect to. Your firewall must let it in.",
text_field(&self.remote_port, 110., window, cx),
p,
));
let key = saved.remote_key.clone();
let shown = if self.show_key {
key.clone()
} else {
format!("{}{}", "•".repeat(12), key.chars().rev().take(4).collect::<Vec<_>>().into_iter().rev().collect::<String>())
};
let copy_key = key.clone();
this_device.push(row(
"Key",
"Other devices need it to connect. A new key disconnects everyone using the old one.",
h_flex()
.gap(px(6.))
.child(
div()
.px(px(10.))
.py(px(6.))
.rounded(px(10.))
.well(p)
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.ink2)
.max_w(px(230.))
.truncate()
.child(shown),
)
.child(
MetalButton::new("show-key")
.ghost()
.mini()
.icon(if self.show_key { IconName::EyeOff } else { IconName::Eye })
.tooltip(if self.show_key { "Hide" } else { "Show" })
.on_click(cx.listener(|this, _, _, cx| {
this.show_key = !this.show_key;
cx.notify();
})),
)
.child(
MetalButton::new("copy-key")
.ghost()
.mini()
.icon(IconName::Copy)
.tooltip("Copy key")
.on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(copy_key.clone()))),
)
.child(
MetalButton::new("new-key")
.ghost()
.mini()
.icon(Lucide::RotateCcw)
.tooltip("Make a new key")
.on_click(cx.listener(|this, _, window, cx| {
settings::update(|settings| settings.remote_key = remote::new_key());
this.changed(window, cx);
})),
),
p,
));
if let Some(address) = addresses.first() {
let code = remote::pairing_code(address, &key);
let copy = code.clone();
this_device.push(row(
"Pairing code",
"Paste it into Settings → Connections on the other device: the address and key in one.",
MetalButton::new("copy-pairing")
.compact()
.icon(IconName::Copy)
.label("Copy pairing code")
.on_click(move |_, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()));
window.push_notification(Notification::success("Pairing code copied."), cx);
}),
p,
));
}
let workspace = self.workspace.clone();
let peer_rows: Vec<AnyElement> = if peers.is_empty() {
vec![row("Connected devices", "None right now.", div(), p)]
} else {
peers
.into_iter()
.map(|peer| {
let workspace = workspace.clone();
let conn = peer.conn;
row(
"Connected device",
format!("{} · {}", peer.name, peer.address),
MetalButton::new(("disconnect-peer", conn as usize))
.ghost()
.compact()
.label("Disconnect")
.on_click(move |_, _, cx| {
let _ = workspace.update(cx, |workspace, cx| workspace.disconnect_peer(conn, cx));
}),
p,
)
})
.collect()
};
this_device.extend(peer_rows);
}
let this_device = section(
"This device",
Some("Connections aren't encrypted. Allow them on networks you trust, or over a VPN such as Tailscale or WireGuard."),
this_device,
p,
);
let mut others: Vec<AnyElement> = saved
.remotes
.iter()
.map(|host| {
let status = statuses.iter().find(|(id, _)| *id == host.id).map(|(_, status)| status.clone());
let (led, text) = match (&status, host.enabled) {
(_, false) => (Led::Off, "Off".to_string()),
(Some(ConnectionStatus::Connected(name)), _) => (Led::Live, format!("Connected to {name}")),
(Some(ConnectionStatus::Connecting) | None, _) => (Led::Waiting, "Connecting…".to_string()),
(Some(ConnectionStatus::Failed(error)), _) => (Led::Failed, format!("Retrying · {error}")),
(Some(ConnectionStatus::Denied(reason)), _) => (Led::Failed, reason.clone()),
};
let (toggle_id, remove_id) = (host.id.clone(), host.id.clone());
let (toggle_view, remove_view) = (view.clone(), view.clone());
h_flex()
.px(px(18.))
.py(px(12.))
.gap(px(12.))
.child(metal::led(SharedString::from(format!("remote-led-{}", host.id)), led, status == Some(ConnectionStatus::Connecting), p))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(2.))
.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(host.label()))
.child(
div()
.truncate()
.text_size(px(12.5))
.text_color(p.ink3)
.child(format!("{} · {text}", remote::with_port(&host.address))),
),
)
.child(metal::toggle(SharedString::from(format!("remote-enabled-{}", host.id)), host.enabled, p, move |on, window, cx| {
let id = toggle_id.clone();
let _ = toggle_view.update(cx, |this, cx| {
this.edit_remote(window, cx, move |hosts| {
if let Some(host) = hosts.iter_mut().find(|host| host.id == id) {
host.enabled = on;
}
})
});
}))
.child(
MetalButton::new(SharedString::from(format!("remove-remote-{}", host.id)))
.ghost()
.mini()
.icon(Lucide::Trash)
.tooltip("Remove")
.on_click(move |_, window, cx| {
let id = remove_id.clone();
let _ = remove_view.update(cx, |this, cx| {
this.edit_remote(window, cx, move |hosts| hosts.retain(|host| host.id != id))
});
}),
)
.into_any_element()
})
.collect();
others.push(
v_flex()
.px(px(18.))
.py(px(14.))
.gap(px(10.))
.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child("Add connection"))
.child(
div()
.text_size(px(12.5))
.text_color(p.ink3)
.child("On the other device, turn on Allow remote connections and copy its pairing code, or its address and key."),
)
.child(
h_flex()
.gap(px(8.))
.child(text_field(&self.connect_name, 200., window, cx).flex_1())
.child(text_field(&self.connect_address, 300., window, cx).flex_1()),
)
.child(
h_flex()
.gap(px(8.))
.child(text_field(&self.connect_key, 300., window, cx).flex_1())
.child(
MetalButton::new("connect-remote")
.primary()
.compact()
.label("Connect")
.on_click(cx.listener(|this, _, window, cx| this.add_connection(window, cx))),
),
)
.into_any_element(),
);
let other_devices = section(
"Other devices",
Some("Their projects and threads appear in the sidebar, tagged with the device's name."),
others,
p,
);
vec![this_device, other_devices]
}
fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let (ctrl_enter, hold) =
settings::get(|settings| (settings.send_with_ctrl_enter, settings.quit_shortcut == QuitShortcut::Hold));
let shortcut = |label: &'static str, keys: &[&'static str]| {
h_flex()
.h(px(44.))
.px(px(18.))
.gap(px(16.))
.child(div().flex_1().text_size(px(13.5)).text_color(p.ink).child(label))
.child(metal::shortcut(keys, p))
.into_any_element()
};
let conversation = section(
"Conversation",
None,
vec![
shortcut("Send message", if ctrl_enter { &["Ctrl", "Enter"] } else { &["Enter"] }),
shortcut("New line", if ctrl_enter { &["Enter"] } else { &["Shift", "Enter"] }),
shortcut(
"Send against the follow-up setting",
if ctrl_enter { &["Ctrl", "Shift", "Enter"] } else { &["Ctrl", "Enter"] },
),
shortcut("Commands and skills", &["/"]),
shortcut("Skills", &["$"]),
shortcut("Interrupt the agent", &["Esc"]),
shortcut("Pick one of the first nine models", &["Ctrl", "1–9"]),
],
p,
);
let app = section(
"Workspace",
None,
vec![
shortcut("New thread", &["Ctrl", "N"]),
shortcut("Add a project folder", &["Ctrl", "O"]),
shortcut("Search threads", &["Ctrl", "K"]),
shortcut("Show or hide the sidebar", &["Ctrl", "B"]),
shortcut("Show or hide the project panel", &["Ctrl", "Shift", "B"]),
shortcut("Open or close settings", &["Ctrl", ","]),
shortcut(if hold { "Quit (hold, or press twice)" } else { "Quit" }, &["Ctrl", "Q"]),
],
p,
);
vec![conversation, app]
}
fn render_archive(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let threads = self
.workspace
.upgrade()
.map(|workspace| workspace.read(cx).settled_threads(cx))
.unwrap_or_default();
if threads.is_empty() {
return vec![section(
"Settled threads",
None,
vec![
div()
.px(px(18.))
.py(px(22.))
.text_size(px(13.))
.text_color(p.ink3)
.child("Nothing settled. Settle a thread from its card to file it away here.")
.into_any_element(),
],
p,
)];
}
let all: Vec<Entity<Session>> = threads.iter().map(|thread| thread.session.clone()).collect();
let rows = threads
.into_iter()
.enumerate()
.map(|(ix, thread)| {
let restore = thread.session.clone();
let forget = thread.session.clone();
let restore_ws = self.workspace.clone();
let forget_ws = self.workspace.clone();
h_flex()
.px(px(18.))
.py(px(12.))
.gap(px(12.))
.child(metal::badge(thread.initials, thread.tint, p))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(2.))
.child(
div()
.truncate()
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(thread.title),
)
.child(
h_flex()
.gap(px(6.))
.text_size(px(12.))
.text_color(p.ink3)
.child(pickers::provider_icon(thread.provider, px(11.), p))
.child(match thread.age.as_str() {
"now" => format!("{} · just now", thread.project),
age => format!("{} · {age} ago", thread.project),
}),
),
)
.child(
MetalButton::new(("restore-thread", ix))
.compact()
.icon(Lucide::ArchiveRestore)
.label("Restore")
.tooltip("Move it back to the thread list and open it")
.on_click(move |_, window, cx| {
let session = restore.clone();
let _ = restore_ws.update(cx, |workspace, cx| workspace.restore_thread(session, window, cx));
}),
)
.child(
MetalButton::new(("delete-settled", ix))
.ghost()
.mini()
.icon(Lucide::Trash)
.tooltip("Delete thread")
.on_click(move |_, window, cx| {
let session = forget.clone();
let _ = forget_ws.update(cx, |workspace, cx| workspace.forget_thread(session, window, cx));
}),
)
.into_any_element()
})
.collect();
let workspace = self.workspace.clone();
let view = cx.weak_entity();
vec![
v_flex()
.gap(px(10.))
.child(
h_flex()
.px(px(4.))
.gap(px(8.))
.child(section_title("Settled threads", p))
.child(div().flex_1())
.child(
MetalButton::new("delete-all-settled")
.ghost()
.compact()
.icon(Lucide::Trash)
.label("Delete all")
.on_click(move |_, window, cx| {
for session in all.clone() {
let _ = workspace
.update(cx, |workspace, cx| workspace.forget_thread(session, window, cx));
}
let _ = view.update(cx, |_, cx| cx.notify());
}),
),
)
.child(
div()
.px(px(4.))
.text_size(px(12.5))
.text_color(p.ink3)
.child("Settled threads keep their transcript; restoring one resumes the conversation with the next message."),
)
.child(card(rows, p))
.into_any_element(),
]
}
fn render_about(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let settings_file = settings::path();
let data = settings::data_dir();
let scratch = workspace::scratch_dir();
let open_button = |id: &'static str, path: Option<std::path::PathBuf>| {
MetalButton::new(id)
.compact()
.icon(IconName::FolderOpen)
.label("Open")
.disabled(path.as_ref().is_none_or(|path| !path.exists()))
.on_click(move |_, _, _| {
if let Some(path) = &path {
open_folder(path);
}
})
};
let folder_row = |title: &'static str, id: &'static str, path: Option<std::path::PathBuf>| {
row(
title,
path.as_deref().map(display_path).unwrap_or_else(|| "Unavailable".into()),
open_button(id, path.clone()),
p,
)
};
let hero = h_flex()
.px(px(4.))
.gap(px(16.))
.child(
div()
.flex()
.items_center()
.justify_center()
.size(px(56.))
.rounded(px(16.))
.raised(p)
.child(Icon::default().data(metal::LOGO_SVG).size(px(28.)).text_color(p.ink)),
)
.child(
v_flex()
.gap(px(4.))
.child(
h_flex()
.gap(px(8.))
.child(div().text_size(px(22.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum Code"))
.child(metal::tag(format!("v{}", env!("CARGO_PKG_VERSION")), p)),
)
.child(
div()
.text_size(px(13.))
.text_color(p.ink3)
.child("A T3 Code-style desktop client for Claude Code and Pi, built with GPUI."),
),
);
let files = section(
"Files",
None,
vec![
folder_row("Settings", "open-settings-dir", settings_file.as_ref().and_then(|path| path.parent()).map(Path::to_path_buf)),
folder_row("Threads", "open-threads-dir", data.as_ref().map(|data| data.join("threads"))),
folder_row("Worktrees", "open-worktrees-dir", data.as_ref().map(|data| data.join("worktrees"))),
folder_row("Threads without a project", "open-scratch-dir", scratch),
],
p,
);
vec![hero.into_any_element(), files]
}
}
impl Focusable for SettingsView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SettingsView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let page = self.render_page(window, cx);
h_flex()
.key_context("SettingsView")
.track_focus(&self.focus_handle)
.size_full()
.on_action(cx.listener(|this, _: &CloseSettings, _, cx| this.close(cx)))
.child(self.render_nav(cx))
.child(
div().flex_1().min_w_0().h_full().py(px(SHEET_INSET)).pr(px(SHEET_INSET)).child(
v_flex()
.size_full()
.rounded(px(SHEET_RADIUS))
.plate(p)
.child(self.render_top_bar(cx))
.child(
div().id(("settings-scroll", self.page as usize)).flex_1().min_h_0().overflow_y_scroll().child(
h_flex().w_full().justify_center().px(px(32.)).py(px(28.)).child(
v_flex()
.w_full()
.max_w(px(if self.page == Page::Usage { 880. } else { PAGE_WIDTH }))
.gap(px(30.))
.children(page),
),
),
),
),
)
}
}
// ---- building blocks ---------------------------------------------------------
/// Saves an edit, then repaints the view and tells the conversations.
fn edit(view: &WeakEntity<SettingsView>, window: &mut Window, cx: &mut App, change: impl FnOnce(&mut Settings)) {
settings::update(change);
let _ = view.update(cx, |view, cx| view.changed(window, cx));
}
fn switch(
id: &'static str,
on: bool,
view: &WeakEntity<SettingsView>,
p: &Palette,
set: impl Fn(&mut Settings, bool) + 'static,
) -> Stateful<Div> {
switch_then(id, on, view, p, set, |_| {})
}
/// A switch that runs `then` after saving.
fn switch_then(
id: &'static str,
on: bool,
view: &WeakEntity<SettingsView>,
p: &Palette,
set: impl Fn(&mut Settings, bool) + 'static,
then: impl Fn(&mut App) + 'static,
) -> Stateful<Div> {
let view = view.clone();
metal::toggle(id, on, p, move |on, window, cx| {
edit(&view, window, cx, |settings| set(settings, on));
then(cx);
})
}
/// A dropdown of fixed choices.
fn choice<T: Copy + PartialEq + 'static>(
id: &'static str,
current: T,
options: &'static [(T, &'static str)],
view: &WeakEntity<SettingsView>,
set: impl Fn(&mut Settings, T) + 'static,
) -> AnyElement {
let label = options.iter().find(|(value, _)| *value == current).map_or("", |(_, label)| *label);
let view = view.clone();
let set = Rc::new(set);
MetalButton::new(id)
.compact()
.label(label)
.caret()
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
menu = menu.min_w(px(200.));
for &(value, label) in options {
let view = view.clone();
let set = set.clone();
menu = menu.item(PopupMenuItem::new(label).checked(value == current).on_click(move |_, window, cx| {
let set = set.clone();
edit(&view, window, cx, move |settings| set(settings, value));
}));
}
menu
})
.into_any_element()
}
/// A number with − and + caps, like T3 Code's interval field.
#[allow(clippy::too_many_arguments)]
fn stepper(
id: &'static str,
value: u64,
step: u64,
(min, max): (u64, u64),
unit: &'static str,
view: &WeakEntity<SettingsView>,
p: &Palette,
set: impl Fn(&mut Settings, u64) + 'static,
) -> Div {
let set = Rc::new(set);
let button = |suffix: &'static str, icon: IconName, next: u64, enabled: bool| {
let view = view.clone();
let set = set.clone();
MetalButton::new(SharedString::from(format!("{id}-{suffix}")))
.ghost()
.mini()
.icon(icon)
.disabled(!enabled)
.on_click(move |_, window, cx| {
let set = set.clone();
edit(&view, window, cx, move |settings| set(settings, next));
})
};
h_flex()
.gap(px(8.))
.child(
h_flex()
.h(px(32.))
.px(px(3.))
.gap(px(2.))
.rounded(px(12.))
.well(p)
.child(button("less", IconName::Minus, value.saturating_sub(step).max(min), value > min))
.child(
div()
.min_w(px(48.))
.flex()
.justify_center()
.font_family(metal::MONO)
.text_size(px(13.))
.text_color(p.ink)
.child(value.to_string()),
)
.child(button("more", IconName::Plus, (value + step).min(max), value < max)),
)
.child(div().text_size(px(12.5)).text_color(p.ink3).child(unit))
}
/// A model dropdown across every enabled provider, with a first entry for
/// "no model" (`none_label`: follow the open thread, or off).
fn model_control(
id: &'static str,
current: Option<String>,
none_label: &'static str,
pick: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
cx: &mut Context<SettingsView>,
) -> AnyElement {
let catalog = Catalog::global(cx);
let p = metal::palette(cx);
let (icon, label): (Option<Provider>, SharedString) = match ¤t {
None => (None, none_label.into()),
Some(key) => match catalog.find_key(key) {
Some(model) => (Some(model.provider), model.name.clone().into()),
None => {
let (instance, model) = split_key(key).unwrap_or((key, key));
let kind = catalog.instance(instance).map(|state| state.kind()).or_else(|| Provider::from_key(instance));
(kind, model.to_string().into())
}
},
};
// One menu section per enabled instance: its label, then its models.
let groups: Vec<(String, Vec<(String, String)>, String)> = catalog
.enabled()
.map(|state| {
let models = state
.models
.iter()
.map(|model| {
let name = match model.provider {
Provider::Claude if model.legacy => format!("{} (legacy)", model.name),
Provider::Claude => model.name.clone(),
Provider::Pi => format!("{} · {}", model.name, model.detail),
};
(model.key(), name)
})
.collect();
let note = match &state.status {
ProbeStatus::Checking => "Asking for its models…".to_string(),
ProbeStatus::Missing => "Not installed".to_string(),
ProbeStatus::Failed(_) => "Didn't list its models".to_string(),
_ => "No models".to_string(),
};
(state.label(), models, note)
})
.collect();
let pick = Rc::new(pick);
MetalButton::new(id)
.compact()
.map(|this| match icon {
Some(provider) => this.icon(pickers::provider_icon(provider, px(13.), p)),
None => this,
})
.label(label)
.caret()
.max_w(px(260.))
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
let item = |key: Option<String>| {
let pick = pick.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(key.clone(), window, cx)
};
menu = menu
.min_w(px(260.))
.max_h(px(440.))
.scrollable(true)
.item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None)));
for (label, models, note) in &groups {
menu = menu.separator().label(label.clone());
if models.is_empty() {
menu = menu.item(PopupMenuItem::new(note.clone()).disabled(true));
}
for (key, name) in models {
let checked = current.as_deref() == Some(key.as_str());
menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click(item(Some(key.clone()))));
}
}
menu
})
.into_any_element()
}
/// A reasoning dropdown, with a first entry for "no level" (`none_label`).
fn reasoning_control(
id: &'static str,
current: Option<Reasoning>,
none_label: &'static str,
pick: impl Fn(Option<Reasoning>, &mut Window, &mut App) + 'static,
) -> AnyElement {
let mut choices = vec![Reasoning::Default];
choices.extend([Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max].map(Reasoning::Level));
choices.extend([Reasoning::Ultracode, Reasoning::Ultrathink]);
let pick = Rc::new(pick);
MetalButton::new(id)
.compact()
.icon(Lucide::Brain)
.label(current.map(Reasoning::label).unwrap_or(none_label))
.caret()
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
let item = |reasoning: Option<Reasoning>| {
let pick = pick.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(reasoning, window, cx)
};
menu = menu
.min_w(px(220.))
.item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None)))
.separator();
for reasoning in choices.clone() {
if reasoning == Reasoning::Default && none_label == "Default" {
continue;
}
if reasoning == Reasoning::Ultracode {
menu = menu.separator().label("Claude only");
}
menu = menu.item(
PopupMenuItem::new(reasoning.label()).checked(current == Some(reasoning)).on_click(item(Some(reasoning))),
);
}
menu
})
.into_any_element()
}
/// The menu "Add provider" opens: one entry per kind.
fn add_provider_menu(
view: WeakEntity<SettingsView>,
) -> impl Fn(gpui_kit::component::menu::PopupMenu, &mut Window, &mut Context<gpui_kit::component::menu::PopupMenu>) -> gpui_kit::component::menu::PopupMenu
+ 'static {
move |mut menu, _, _| {
menu = menu.min_w(px(220.)).label("Another instance of");
for kind in Provider::ALL {
let view = view.clone();
menu = menu.item(
PopupMenuItem::new(match kind {
Provider::Claude => "Claude Code",
Provider::Pi => "Pi",
})
.on_click(move |_, window, cx| {
let _ = view.update(cx, |this, cx| this.add_provider(kind, window, cx));
}),
);
}
menu
}
}
/// `Authenticated · Claude Team Subscription`, `Disabled`, `Not installed`.
fn status_line(state: &InstanceState) -> String {
match &state.status {
ProbeStatus::Disabled => "Disabled".into(),
ProbeStatus::Checking => "Checking…".into(),
ProbeStatus::Missing => "Not installed".into(),
ProbeStatus::Failed(error) => 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 signed in".to_string()),
Provider::Pi if state.models.is_empty() => parts.push("No models configured".to_string()),
Provider::Pi => parts.push(format!("{} models", state.models.len())),
}
parts.extend(state.plan.clone());
if state.update_available() {
parts.push("Update available".into());
}
parts.join(" · ")
}
}
}
/// `2m ago`, `just now`.
fn ago(time: SystemTime) -> String {
let secs = time.elapsed().unwrap_or_default().as_secs();
match secs {
0..60 => "just now".into(),
60..3_600 => format!("{}m ago", secs / 60),
3_600..86_400 => format!("{}h ago", secs / 3_600),
_ => format!("{}d ago", secs / 86_400),
}
}
pub(crate) fn section_title(title: &'static str, p: &Palette) -> Div {
div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(title)
}
/// A raised card of rows, with hairlines between them.
pub(crate) fn card(rows: Vec<AnyElement>, p: &Palette) -> Div {
let count = rows.len();
v_flex().rounded(px(18.)).raised(p).children(rows.into_iter().enumerate().flat_map(|(ix, row)| {
let rule = (ix + 1 < count).then(|| div().px(px(18.)).child(metal::rule(p)).into_any_element());
std::iter::once(row).chain(rule)
}))
}
pub(crate) fn section(title: &'static str, description: Option<&'static str>, rows: Vec<AnyElement>, p: &Palette) -> AnyElement {
v_flex()
.gap(px(10.))
.child(
v_flex()
.px(px(4.))
.gap(px(3.))
.child(section_title(title, p))
.when_some(description, |this, description| {
this.child(div().text_size(px(12.5)).text_color(p.ink3).child(description))
}),
)
.child(card(rows, p))
.into_any_element()
}
/// A smaller heading over a card, inside a page section.
fn subsection(title: &'static str, card: Div, p: &Palette) -> Div {
v_flex()
.gap(px(8.))
.child(div().px(px(4.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(title))
.child(card)
}
/// One setting: its name and what it does on the left, the control on the right.
pub(crate) fn row(
title: &'static str,
description: impl Into<SharedString>,
control: impl IntoElement,
p: &Palette,
) -> AnyElement {
h_flex()
.px(px(18.))
.py(px(14.))
.gap(px(24.))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap(px(3.))
.child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title))
.child(div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(description.into())),
)
.child(div().flex_none().child(control))
.into_any_element()
}
/// A text field, sunk into a well.
fn text_field(input: &Entity<InputState>, width: f32, _: &mut Window, cx: &App) -> Div {
let p = metal::palette(cx);
h_flex()
.w(px(width))
.h(px(32.))
.px(px(11.))
.rounded(px(12.))
.well(p)
.font_family(metal::MONO)
.text_size(px(12.))
.text_color(p.ink)
.child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small()))
}
/// 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
}
}
/// A radio dot: a well, with a raised green bead when picked.
pub(crate) fn radio(selected: bool, p: &Palette) -> Div {
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(16.))
.rounded_full()
.well(p)
.when(selected, |this| {
this.child(div().size(px(8.)).rounded_full().bg(p.green_deep))
})
}
fn open_folder(path: &Path) {
let Some(program) = workspace::find_program(&["xdg-open"]) else { return };
let spawned = Command::new(program)
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = spawned {
std::thread::spawn(move || child.wait());
}
}