//! 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},
};
use gpui_kit::component::{
Icon, IconName, Sizable as _, h_flex,
input::{Input, InputEvent, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::CloseSettings;
use crate::claude::{self, Effort, PermissionMode};
use crate::models::{Catalog, PiStatus, Provider};
use crate::session::{Reasoning, Session};
use crate::settings::{self, Settings};
use crate::ui::chat::display_path;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, BONE, Colorway, GRAPHITE, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers;
use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
const PAGE_WIDTH: f32 = 720.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
General,
Appearance,
Providers,
Keybindings,
Archive,
About,
}
impl Page {
const ALL: [Page; 6] =
[Page::General, Page::Appearance, Page::Providers, Page::Keybindings, Page::Archive, Page::About];
fn label(self) -> &'static str {
match self {
Page::General => "General",
Page::Appearance => "Appearance",
Page::Providers => "Providers",
Page::Keybindings => "Keybindings",
Page::Archive => "Archive",
Page::About => "About",
}
}
fn icon(self) -> Icon {
match self {
Page::General => Icon::new(Lucide::SlidersHorizontal),
Page::Appearance => Icon::new(IconName::Palette),
Page::Providers => Icon::new(Lucide::Plug),
Page::Keybindings => Icon::new(Lucide::Keyboard),
Page::Archive => Icon::new(Lucide::Archive),
Page::About => Icon::new(IconName::Info),
}
}
}
pub enum SettingsEvent {
Close,
}
pub struct SettingsView {
workspace: WeakEntity<Workspace>,
page: Page,
claude_bin: Entity<InputState>,
pi_bin: Entity<InputState>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<SettingsEvent> for SettingsView {}
impl SettingsView {
pub fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let saved = settings::load();
let binary_input = |value: Option<String>, window: &mut Window, cx: &mut Context<Self>| {
cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Look it up on PATH")
.default_value(value.unwrap_or_default())
})
};
let claude_bin = binary_input(saved.claude_bin, window, cx);
let pi_bin = binary_input(saved.pi_bin, window, cx);
let save_on = |provider: Provider| {
move |this: &mut Self, _: &Entity<InputState>, event: &InputEvent, window: &mut Window, cx: &mut Context<Self>| {
if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
this.save_binary(provider, window, cx);
}
}
};
let subscriptions = vec![
cx.subscribe_in(&claude_bin, window, save_on(Provider::Claude)),
cx.subscribe_in(&pi_bin, window, save_on(Provider::Pi)),
];
Self {
workspace,
page: Page::General,
claude_bin,
pi_bin,
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 saved = settings::load();
for (input, value) in [(&self.claude_bin, saved.claude_bin), (&self.pi_bin, saved.pi_bin)] {
input.update(cx, |input, cx| input.set_value(value.unwrap_or_default(), window, cx));
}
}
fn save_binary(&mut self, provider: Provider, window: &mut Window, cx: &mut Context<Self>) {
let input = match provider {
Provider::Claude => &self.claude_bin,
Provider::Pi => &self.pi_bin,
};
let value = Some(input.read(cx).value().trim().to_string()).filter(|value| !value.is_empty());
fn field(settings: &mut Settings, provider: Provider) -> &mut Option<String> {
match provider {
Provider::Claude => &mut settings.claude_bin,
Provider::Pi => &mut settings.pi_bin,
}
}
if settings::get(|settings| *field(&mut settings.clone(), provider) == value) {
return;
}
settings::update(|settings| *field(settings, provider) = value);
if provider == Provider::Pi {
Catalog::refresh_pi(cx);
}
self.changed(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>) {
let pi_bin = settings::get(|settings| settings.pi_bin.clone());
settings::restore_defaults();
metal::reload_colorway(window, cx);
if pi_bin.is_some() {
Catalog::refresh_pi(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);
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;
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;
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(page == Page::Archive && settled > 0, |this| {
this.child(
div()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(settled.to_string()),
)
})
})),
)
.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::Appearance);
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")
.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(cx),
Page::Appearance => self.render_appearance(cx),
Page::Providers => self.render_providers(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, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let saved = settings::load();
let view = cx.weak_entity();
let new_threads = section(
"New threads",
None,
vec![
row(
"Model",
"The model a new thread starts on. Following the open thread keeps whatever you picked last.",
self.model_control(&saved, cx),
p,
),
row(
"Reasoning",
"How hard new threads think, where their model supports the level.",
self.reasoning_control(&saved, cx),
p,
),
row(
"Access",
"How much Claude may do without asking. Pi threads always run with full access.",
self.access_control(&saved, cx),
p,
),
],
p,
);
let conversation = section(
"Conversation",
None,
vec![
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(
"Send with Ctrl+Enter",
"Enter starts a new line instead of sending.",
switch("ctrl-enter", saved.send_with_ctrl_enter, &view, p, |settings, on| {
settings.send_with_ctrl_enter = 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,
),
],
p,
);
let can_notify = workspace::find_program(&["notify-send"]).is_some();
let integrations = section(
"Desktop",
None,
vec![
row("Open in", "The editor the project panel opens projects in.", self.editor_control(cx), p),
row(
"Notifications",
if can_notify {
"Notify when a thread finishes or waits for you while the window is in the background."
} else {
"Needs notify-send (libnotify), which isn't installed."
},
div().when(!can_notify, |this| this.opacity(0.4)).child(switch(
"notify",
saved.notify && can_notify,
&view,
p,
move |settings, on| settings.notify = on && can_notify,
)),
p,
),
],
p,
);
vec![new_threads, conversation, integrations]
}
fn model_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
let catalog = Catalog::global(cx);
let current = saved.default_model.clone();
let (icon, label): (Option<Provider>, SharedString) = match ¤t {
None => (None, "Follow open thread".into()),
Some(key) => match catalog.find_key(key) {
Some(model) => (Some(model.provider), model.name.clone().into()),
None => (
key.split_once(':').and_then(|(provider, _)| Provider::from_key(provider)),
key.split_once(':').map(|(_, id)| id).unwrap_or(key).to_string().into(),
),
},
};
let claude: Vec<(String, String, bool)> =
catalog.claude.iter().map(|model| (model.key(), model.name.clone(), model.legacy)).collect();
let pi: Vec<(String, String, String)> =
catalog.pi.iter().map(|model| (model.key(), model.name.clone(), model.detail.clone())).collect();
let pi_status = catalog.pi_status.clone();
let view = cx.weak_entity();
let p = metal::palette(cx);
MetalButton::new("default-model")
.compact()
.map(|this| match icon {
Some(provider) => this.icon(pickers::provider_icon(provider, px(13.), p)),
None => this,
})
.label(label)
.caret()
.max_w(px(280.))
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
let pick = |key: Option<String>| {
let view = view.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
let key = key.clone();
edit(&view, window, cx, move |settings| settings.default_model = key);
}
};
menu = menu
.min_w(px(260.))
.max_h(px(440.))
.scrollable(true)
.item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
.separator()
.label("Claude");
if claude.is_empty() {
menu = menu.item(PopupMenuItem::new("Listed once a Claude thread has started").disabled(true));
}
for (key, name, legacy) in claude.clone() {
let label = if legacy { format!("{name} (legacy)") } else { name };
let checked = current.as_deref() == Some(key.as_str());
menu = menu.item(PopupMenuItem::new(label).checked(checked).on_click(pick(Some(key))));
}
menu = menu.separator().label("Pi");
if pi.is_empty() {
let note = match &pi_status {
PiStatus::Loading => "Asking Pi for its models…",
PiStatus::Missing => "Pi isn't installed",
PiStatus::Failed(_) => "Pi didn't list its models",
PiStatus::Ready => "No models configured",
};
menu = menu.item(PopupMenuItem::new(note).disabled(true));
}
for (key, name, detail) in pi.clone() {
let checked = current.as_deref() == Some(key.as_str());
menu = menu
.item(PopupMenuItem::new(format!("{name} · {detail}")).checked(checked).on_click(pick(Some(key))));
}
menu
})
.into_any_element()
}
fn reasoning_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
let current = saved.default_reasoning.as_deref().and_then(Reasoning::from_key);
let view = cx.weak_entity();
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]);
MetalButton::new("default-reasoning")
.compact()
.icon(Lucide::Brain)
.label(current.map(Reasoning::label).unwrap_or("Follow open thread"))
.caret()
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
let pick = |reasoning: Option<Reasoning>| {
let view = view.clone();
move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
edit(&view, window, cx, move |settings| {
settings.default_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
});
}
};
menu = menu
.min_w(px(220.))
.item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
.separator();
for reasoning in choices.clone() {
if reasoning == Reasoning::Ultracode {
menu = menu.separator().label("Claude only");
}
menu = menu.item(
PopupMenuItem::new(reasoning.label())
.checked(current == Some(reasoning))
.on_click(pick(Some(reasoning))),
);
}
menu
})
.into_any_element()
}
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 current = metal::colorway(cx);
let view = cx.weak_entity();
let compact = settings::get(|settings| settings.compact_threads);
let swatches = h_flex().p(px(18.)).gap(px(16.)).children(Colorway::ALL.into_iter().map(|colorway| {
let selected = colorway == current;
let view = view.clone();
let preview = match colorway {
Colorway::Bone => colorway_preview(&BONE).flex_1(),
Colorway::Graphite => colorway_preview(&GRAPHITE).flex_1(),
Colorway::Auto => h_flex()
.flex_1()
.h_full()
.child(colorway_preview(&BONE).flex_1().rounded_r(px(0.)))
.child(colorway_preview(&GRAPHITE).flex_1().rounded_l(px(0.))),
};
let mut ring = vec![BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))];
if selected {
ring = vec![BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))];
}
v_flex()
.id(SharedString::from(format!("colorway-{}", colorway.label())))
.flex_1()
.gap(px(10.))
.cursor_pointer()
.on_click(move |_, window, cx| {
metal::set_colorway(colorway, window, cx);
let _ = view.update(cx, |_, cx| cx.notify());
})
.child(h_flex().h(px(96.)).rounded(px(14.)).shadow(ring).child(preview))
.child(
h_flex()
.gap(px(8.))
.child(radio(selected, p))
.child(
div()
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(colorway.label()),
)
.when(colorway == Colorway::Auto, |this| {
this.child(div().text_size(px(12.)).text_color(p.ink3).child("Follows the desktop"))
}),
)
}));
let colorways = section(
"Colorway",
Some("MetalUI's two finishes: warm Bone and dark Graphite."),
vec![swatches.into_any_element()],
p,
);
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![colorways, sidebar]
}
fn render_providers(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let catalog = Catalog::global(cx);
let account = catalog.claude_account.clone();
let claude_models = catalog.claude.iter().filter(|model| !model.legacy).count();
let pi_status = catalog.pi_status.clone();
let pi_models = catalog.pi.len();
let claude = section(
"Claude Code",
Some("Threads talk to the claude CLI over its stream-json protocol."),
vec![
row(
"Account",
match &account {
Some(account) => format!("Signed in as {account}."),
None => "Shown once a Claude thread has started.".to_string(),
},
metal::led("claude-led", if account.is_some() { Led::Live } else { Led::Off }, false, p),
p,
),
row(
"Models",
if claude_models > 0 {
format!("{claude_models} current models, plus legacy ones in the picker.")
} else {
"Listed once a Claude thread has started.".to_string()
},
div(),
p,
),
row(
"Binary",
binary_status(claude::find_claude_binary().as_deref(), "VECTRUM_CODE_CLAUDE_BIN"),
path_field(&self.claude_bin, window, cx),
p,
),
],
p,
);
let (led, status) = match &pi_status {
PiStatus::Loading => (Led::Waiting, "Asking Pi for its models…".to_string()),
PiStatus::Ready if pi_models > 0 => (Led::Live, format!("{pi_models} models across your Pi providers.")),
PiStatus::Ready => (Led::Waiting, "No models configured. Log in to a provider with `pi`.".to_string()),
PiStatus::Missing => (
Led::Off,
"Not installed: npm install -g @earendil-works/pi-coding-agent, then log in with `pi`.".to_string(),
),
PiStatus::Failed(error) => (Led::Failed, format!("Pi didn't list its models: {error}")),
};
let pi = section(
"Pi",
Some("Threads talk to pi --mode rpc. Its tools run without approval prompts."),
vec![
row(
"Models",
status,
h_flex().gap(px(10.)).child(metal::led("pi-led", led, pi_status == PiStatus::Loading, p)).child(
MetalButton::new("refresh-pi")
.compact()
.icon(IconName::RefreshCw)
.label("Refresh")
.disabled(pi_status == PiStatus::Loading)
.on_click(|_, _, cx| Catalog::refresh_pi(cx)),
),
p,
),
row(
"Binary",
binary_status(claude::find_pi_binary().as_deref(), "VECTRUM_CODE_PI_BIN"),
path_field(&self.pi_bin, window, cx),
p,
),
],
p,
);
vec![claude, pi]
}
fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let p = metal::palette(cx);
let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
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("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("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 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.is_none())
.on_click(move |_, _, _| {
if let Some(path) = &path {
open_folder(path);
}
})
};
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![
row(
"Settings",
settings_file.as_deref().map(display_path).unwrap_or_else(|| "Unavailable".into()),
open_button("open-settings-dir", settings_file.as_ref().and_then(|path| path.parent()).map(Path::to_path_buf)),
p,
),
row(
"Threads without a project",
scratch.as_deref().map(display_path).unwrap_or_else(|| "Unavailable".into()),
open_button("open-scratch-dir", scratch.clone()),
p,
),
],
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(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> {
let view = view.clone();
metal::toggle(id, on, p, move |on, window, cx| edit(&view, window, cx, |settings| set(settings, on)))
}
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.
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)
}))
}
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()
}
/// One setting: its name and what it does on the left, the control on the right.
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 path field, sunk into a well.
fn path_field(input: &Entity<InputState>, _: &mut Window, cx: &App) -> Div {
let p = metal::palette(cx);
h_flex()
.w(px(260.))
.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>, env: &str) -> String {
let found = 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(),
};
if std::env::var_os(env).is_some() {
format!("{found} Set by {env}, which wins over this field.")
} else {
found
}
}
/// A radio dot: a well, with a raised green bead when picked.
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))
})
}
/// A miniature of the app in a colorway: the sheet on the table, a thread
/// list and a composer.
fn colorway_preview(c: &Palette) -> Div {
let line = |width: f32, color: Hsla| div().h(px(4.)).w(px(width)).rounded_full().bg(color);
h_flex()
.h_full()
.overflow_hidden()
.rounded(px(14.))
.bg(c.page)
.p(px(8.))
.gap(px(8.))
.child(
v_flex()
.w(px(26.))
.gap(px(6.))
.pt(px(4.))
.child(line(22., c.ink2.opacity(0.6)))
.child(line(16., c.ink3.opacity(0.5)))
.child(line(20., c.ink3.opacity(0.5))),
)
.child(
v_flex()
.flex_1()
.h_full()
.p(px(8.))
.gap(px(6.))
.rounded(px(10.))
.bg(metal::vertical((c.s_hi, c.s)))
.shadow(c.raise_sm.clone())
.child(line(40., c.ink.opacity(0.7)))
.child(line(56., c.ink3.opacity(0.5)))
.child(div().flex_1())
.child(
h_flex()
.h(px(16.))
.px(px(4.))
.rounded(px(6.))
.bg(metal::vertical(c.row_on))
.shadow(c.raise_sm.clone())
.child(div().flex_1())
.child(div().size(px(8.)).rounded_full().bg(metal::vertical(c.primary))),
),
)
}
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());
}
}