//! 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, 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 = 760.; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Page { General, Behavior, Appearance, Providers, Connections, Keybindings, Archive, About, } impl Page { const ALL: [Page; 8] = [ Page::General, Page::Behavior, Page::Appearance, Page::Providers, 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::Providers => "Providers", 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::Providers => Icon::new(Lucide::Plug), 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, binary: Entity, home: Entity, args: Entity, env: Vec<(Entity, Entity)>, _subscriptions: Vec, } pub struct SettingsView { workspace: WeakEntity, page: Page, add_project_dir: Entity, provider_form: Option, remote_port: Entity, /// The "Add connection" form: name, address or pairing code, key. connect_name: Entity, connect_address: Entity, connect_key: Entity, show_key: bool, /// Instances an update is running for. updating: Vec, focus_handle: FocusHandle, _subscriptions: Vec, } impl EventEmitter 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, window: &mut Window, cx: &mut Context) -> 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::().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, 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.reload_inputs(window, cx); self.focus_handle.focus(window, cx); cx.notify(); } fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context) { 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) { 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) { settings::restore_defaults(); metal::reload_colorway(window, 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); 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; 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) -> impl IntoElement { let p = metal::palette(cx); let resettable = matches!(self.page, Page::General | Page::Behavior | 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, 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) -> Vec { match self.page { Page::General => self.render_general(window, cx), Page::Behavior => self.render_behavior(cx), Page::Appearance => self.render_appearance(cx), Page::Providers => self.render_providers(window, cx), 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) -> Vec { let p = metal::palette(cx); let saved = settings::load(); let view = cx.weak_entity(); let model_pick = { let view = view.clone(); move |key: Option, 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, 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, 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, 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) -> Vec { 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) -> 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] } // ---- providers --------------------------------------------------------- /// Opens an instance's fields on the Providers page. fn select_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let Some(instance) = settings::get(|settings| settings.instance(id).cloned()) else { self.provider_form = None; cx.notify(); return; }; let input = |value: &str, placeholder: &str, window: &mut Window, cx: &mut Context| { 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) { let Some(form) = &self.provider_form else { return }; let value = |input: &Entity| 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 = 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) { 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) { let ids: Vec = 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) { 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) { 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) { 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) { 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) -> Vec { let p = metal::palette(cx); let catalog = Catalog::global(cx); let instances: Vec = 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, advanced] } fn render_provider_detail( &self, state: &InstanceState, form: &ProviderForm, window: &mut Window, cx: &mut Context, ) -> 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, window: &mut Window, cx: &mut Context| { 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 = 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() } // ---- connections ------------------------------------------------------- /// Saves the "Add connection" form as a new connection. fn add_connection(&mut self, window: &mut Window, cx: &mut Context) { let value = |input: &Entity| 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 = 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, change: impl FnOnce(&mut Vec)) { settings::update(|settings| change(&mut settings.remotes)); self.changed(window, cx); } fn render_connections(&self, window: &mut Window, cx: &mut Context) -> Vec { 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 = 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::>().into_iter().rev().collect::()) }; 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 = 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 = 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, 150., window, cx)) .child(text_field(&self.connect_address, 220., window, cx)) .child(text_field(&self.connect_key, 150., window, cx)) .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) -> Vec { 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) -> 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 data = settings::data_dir(); let scratch = workspace::scratch_dir(); let open_button = |id: &'static str, path: Option| { 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| { 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) -> 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
{ switch_then(id, on, view, p, set, |_| {}) } /// A switch that runs `then` after saving. fn switch_then( id: &'static str, on: bool, view: &WeakEntity, p: &Palette, set: impl Fn(&mut Settings, bool) + 'static, then: impl Fn(&mut App) + 'static, ) -> Stateful
{ 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( id: &'static str, current: T, options: &'static [(T, &'static str)], view: &WeakEntity, 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, 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, none_label: &'static str, pick: impl Fn(Option, &mut Window, &mut App) + 'static, cx: &mut Context, ) -> AnyElement { let catalog = Catalog::global(cx); let p = metal::palette(cx); let (icon, label): (Option, 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| { 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, none_label: &'static str, pick: impl Fn(Option, &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| { 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, ) -> impl Fn(gpui_kit::component::menu::PopupMenu, &mut Window, &mut Context) -> 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, 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, 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, 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, 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. 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()); } }