//! 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, page: Page, claude_bin: Entity, pi_bin: Entity, focus_handle: FocusHandle, _subscriptions: Vec, } impl EventEmitter for SettingsView {} impl SettingsView { pub fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let saved = settings::load(); let binary_input = |value: Option, window: &mut Window, cx: &mut Context| { 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, event: &InputEvent, window: &mut Window, cx: &mut Context| { 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.reload_inputs(window, cx); self.focus_handle.focus(window, cx); cx.notify(); } fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context) { 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) { 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 { 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) { 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) { 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) { cx.emit(SettingsEvent::Close); } // ---- frame ------------------------------------------------------------- fn render_nav(&self, cx: &mut Context) -> 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) -> 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) -> Vec { 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) -> Vec { 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) -> AnyElement { let catalog = Catalog::global(cx); let current = saved.default_model.clone(); let (icon, label): (Option, 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| { 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) -> 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| { 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) -> 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| { 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) -> 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) -> Vec { 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) -> Vec { 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) -> Vec { 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) -> Vec { 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> = 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) -> Vec { let p = metal::palette(cx); let settings_file = settings::path(); let scratch = workspace::scratch_dir(); let open_button = |id: &'static str, path: Option| { 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) -> 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, 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, p: &Palette, set: impl Fn(&mut Settings, bool) + 'static, ) -> Stateful
{ 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, 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, 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, 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, _: &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()); } }