Vectrum
Sign In
1191 lines · 47.1 KB · Rust
//! The settings view, laid out like T3 Code's: the pages listed where the
//! thread list was, and the open page on the sheet. Every change is saved at
//! once and handed to the open conversations.

use std::{
    path::Path,
    process::{Command, Stdio},
};

use gpui_kit::component::{
    Icon, IconName, Sizable as _, h_flex,
    input::{Input, InputEvent, InputState},
    menu::{DropdownMenu as _, PopupMenuItem},
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use crate::CloseSettings;
use crate::claude::{self, Effort, PermissionMode};
use crate::models::{Catalog, PiStatus, Provider};
use crate::session::{Reasoning, Session};
use crate::settings::{self, Settings};
use crate::ui::chat::display_path;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, BONE, Colorway, GRAPHITE, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers;
use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};

const PAGE_WIDTH: f32 = 720.;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Page {
    General,
    Appearance,
    Providers,
    Keybindings,
    Archive,
    About,
}

impl Page {
    const ALL: [Page; 6] =
        [Page::General, Page::Appearance, Page::Providers, Page::Keybindings, Page::Archive, Page::About];

    fn label(self) -> &'static str {
        match self {
            Page::General => "General",
            Page::Appearance => "Appearance",
            Page::Providers => "Providers",
            Page::Keybindings => "Keybindings",
            Page::Archive => "Archive",
            Page::About => "About",
        }
    }

    fn icon(self) -> Icon {
        match self {
            Page::General => Icon::new(Lucide::SlidersHorizontal),
            Page::Appearance => Icon::new(IconName::Palette),
            Page::Providers => Icon::new(Lucide::Plug),
            Page::Keybindings => Icon::new(Lucide::Keyboard),
            Page::Archive => Icon::new(Lucide::Archive),
            Page::About => Icon::new(IconName::Info),
        }
    }
}

pub enum SettingsEvent {
    Close,
}

pub struct SettingsView {
    workspace: WeakEntity<Workspace>,
    page: Page,
    claude_bin: Entity<InputState>,
    pi_bin: Entity<InputState>,
    focus_handle: FocusHandle,
    _subscriptions: Vec<Subscription>,
}

impl EventEmitter<SettingsEvent> for SettingsView {}

impl SettingsView {
    pub fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let saved = settings::load();
        let binary_input = |value: Option<String>, window: &mut Window, cx: &mut Context<Self>| {
            cx.new(|cx| {
                InputState::new(window, cx)
                    .placeholder("Look it up on PATH")
                    .default_value(value.unwrap_or_default())
            })
        };
        let claude_bin = binary_input(saved.claude_bin, window, cx);
        let pi_bin = binary_input(saved.pi_bin, window, cx);
        let save_on = |provider: Provider| {
            move |this: &mut Self, _: &Entity<InputState>, event: &InputEvent, window: &mut Window, cx: &mut Context<Self>| {
                if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
                    this.save_binary(provider, window, cx);
                }
            }
        };
        let subscriptions = vec![
            cx.subscribe_in(&claude_bin, window, save_on(Provider::Claude)),
            cx.subscribe_in(&pi_bin, window, save_on(Provider::Pi)),
        ];
        Self {
            workspace,
            page: Page::General,
            claude_bin,
            pi_bin,
            focus_handle: cx.focus_handle(),
            _subscriptions: subscriptions,
        }
    }

    /// Readies the view for showing: the fields reflect the saved settings,
    /// and Esc closes it.
    pub fn opened(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        self.reload_inputs(window, cx);
        self.focus_handle.focus(window, cx);
        cx.notify();
    }

    fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let saved = settings::load();
        for (input, value) in [(&self.claude_bin, saved.claude_bin), (&self.pi_bin, saved.pi_bin)] {
            input.update(cx, |input, cx| input.set_value(value.unwrap_or_default(), window, cx));
        }
    }

    fn save_binary(&mut self, provider: Provider, window: &mut Window, cx: &mut Context<Self>) {
        let input = match provider {
            Provider::Claude => &self.claude_bin,
            Provider::Pi => &self.pi_bin,
        };
        let value = Some(input.read(cx).value().trim().to_string()).filter(|value| !value.is_empty());
        fn field(settings: &mut Settings, provider: Provider) -> &mut Option<String> {
            match provider {
                Provider::Claude => &mut settings.claude_bin,
                Provider::Pi => &mut settings.pi_bin,
            }
        }
        if settings::get(|settings| *field(&mut settings.clone(), provider) == value) {
            return;
        }
        settings::update(|settings| *field(settings, provider) = value);
        if provider == Provider::Pi {
            Catalog::refresh_pi(cx);
        }
        self.changed(window, cx);
    }

    /// Repaints, and lets the open conversations pick up the change.
    fn changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let _ = self.workspace.update(cx, |workspace, cx| workspace.settings_changed(window, cx));
        cx.notify();
    }

    fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let pi_bin = settings::get(|settings| settings.pi_bin.clone());
        settings::restore_defaults();
        metal::reload_colorway(window, cx);
        if pi_bin.is_some() {
            Catalog::refresh_pi(cx);
        }
        self.reload_inputs(window, cx);
        self.changed(window, cx);
    }

    fn close(&mut self, cx: &mut Context<Self>) {
        cx.emit(SettingsEvent::Close);
    }

    // ---- frame -------------------------------------------------------------

    fn render_nav(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let settled = self
            .workspace
            .upgrade()
            .map(|workspace| workspace.read(cx).settled_threads(cx).len())
            .unwrap_or(0);

        v_flex()
            .w(px(SIDEBAR_WIDTH))
            .h_full()
            .flex_none()
            .child(
                h_flex()
                    .flex_none()
                    .h(px(52.))
                    .px(px(16.))
                    .gap(px(8.))
                    .child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
                    .child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
                    .child(metal::tag("Settings", p)),
            )
            .child(
                v_flex()
                    .flex_1()
                    .min_h_0()
                    .px(px(8.))
                    .pt(px(12.))
                    .gap(px(3.))
                    .children(Page::ALL.into_iter().map(|page| {
                        let selected = page == self.page;
                        h_flex()
                            .id(SharedString::from(format!("settings-page-{}", page.label())))
                            .h(px(38.))
                            .px(px(12.))
                            .gap(px(10.))
                            .rounded(px(13.))
                            .cursor_pointer()
                            .text_size(px(13.5))
                            .font_weight(FontWeight::MEDIUM)
                            .map(|this| {
                                if selected {
                                    this.raised(p).text_color(p.ink)
                                } else {
                                    this.text_color(p.ink2).hover(|style| style.bg(p.row_hover).text_color(p.ink))
                                }
                            })
                            .on_click(cx.listener(move |this, _, _, cx| {
                                this.page = page;
                                cx.notify();
                            }))
                            .child(page.icon().size(px(15.)).text_color(if selected { p.ink } else { p.ink2 }))
                            .child(div().flex_1().child(page.label()))
                            .when(page == Page::Archive && settled > 0, |this| {
                                this.child(
                                    div()
                                        .font_family(metal::MONO)
                                        .text_size(px(10.5))
                                        .text_color(p.ink3)
                                        .child(settled.to_string()),
                                )
                            })
                    })),
            )
            .child(
                h_flex()
                    .flex_none()
                    .h(px(48.))
                    .px(px(12.))
                    .gap(px(8.))
                    .child(
                        MetalButton::new("settings-back")
                            .ghost()
                            .compact()
                            .icon(IconName::ArrowLeft)
                            .label("Back")
                            .tooltip("Back to threads (Esc)")
                            .on_click(cx.listener(|this, _, _, cx| this.close(cx))),
                    )
                    .child(div().flex_1())
                    .child(metal::engraved(format!("v{}", env!("CARGO_PKG_VERSION")), p)),
            )
    }

    fn render_top_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let resettable = matches!(self.page, Page::General | Page::Appearance);
        v_flex()
            .flex_none()
            .child(
                h_flex()
                    .h(px(52.))
                    .pl(px(20.))
                    .pr(px(12.))
                    .gap(px(10.))
                    .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Settings"))
                    .child(div().text_size(px(14.)).text_color(p.ink3).child("/"))
                    .child(
                        div()
                            .text_size(px(14.))
                            .font_weight(FontWeight::SEMIBOLD)
                            .text_color(p.ink)
                            .child(self.page.label()),
                    )
                    .child(div().flex_1())
                    .when(resettable, |this| {
                        this.child(
                            MetalButton::new("restore-defaults")
                                .ghost()
                                .compact()
                                .icon(Lucide::RotateCcw)
                                .label("Restore defaults")
                                .tooltip("Reset every setting except favorites")
                                .on_click(cx.listener(|this, _, window, cx| this.restore_defaults(window, cx))),
                        )
                    })
                    .child(
                        MetalButton::new("close-settings")
                            .ghost()
                            .mini()
                            .icon(IconName::Close)
                            .tooltip("Close (Esc)")
                            .on_click(cx.listener(|this, _, _, cx| this.close(cx))),
                    ),
            )
            .child(metal::rule(p))
    }

    fn render_page(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
        match self.page {
            Page::General => self.render_general(cx),
            Page::Appearance => self.render_appearance(cx),
            Page::Providers => self.render_providers(window, cx),
            Page::Keybindings => self.render_keybindings(cx),
            Page::Archive => self.render_archive(cx),
            Page::About => self.render_about(cx),
        }
    }

    // ---- pages -------------------------------------------------------------

    fn render_general(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let saved = settings::load();
        let view = cx.weak_entity();

        let new_threads = section(
            "New threads",
            None,
            vec![
                row(
                    "Model",
                    "The model a new thread starts on. Following the open thread keeps whatever you picked last.",
                    self.model_control(&saved, cx),
                    p,
                ),
                row(
                    "Reasoning",
                    "How hard new threads think, where their model supports the level.",
                    self.reasoning_control(&saved, cx),
                    p,
                ),
                row(
                    "Access",
                    "How much Claude may do without asking. Pi threads always run with full access.",
                    self.access_control(&saved, cx),
                    p,
                ),
            ],
            p,
        );

        let conversation = section(
            "Conversation",
            None,
            vec![
                row(
                    "Fold finished turns",
                    "Tuck a finished turn's tool calls and interim messages into one \u{201c}Worked for\u{201d} row.",
                    switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on),
                    p,
                ),
                row(
                    "Send with Ctrl+Enter",
                    "Enter starts a new line instead of sending.",
                    switch("ctrl-enter", saved.send_with_ctrl_enter, &view, p, |settings, on| {
                        settings.send_with_ctrl_enter = on
                    }),
                    p,
                ),
                row(
                    "Show costs",
                    "What each turn and thread cost, in the transcript and the project panel.",
                    switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on),
                    p,
                ),
            ],
            p,
        );

        let can_notify = workspace::find_program(&["notify-send"]).is_some();
        let integrations = section(
            "Desktop",
            None,
            vec![
                row("Open in", "The editor the project panel opens projects in.", self.editor_control(cx), p),
                row(
                    "Notifications",
                    if can_notify {
                        "Notify when a thread finishes or waits for you while the window is in the background."
                    } else {
                        "Needs notify-send (libnotify), which isn't installed."
                    },
                    div().when(!can_notify, |this| this.opacity(0.4)).child(switch(
                        "notify",
                        saved.notify && can_notify,
                        &view,
                        p,
                        move |settings, on| settings.notify = on && can_notify,
                    )),
                    p,
                ),
            ],
            p,
        );

        vec![new_threads, conversation, integrations]
    }

    fn model_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
        let catalog = Catalog::global(cx);
        let current = saved.default_model.clone();
        let (icon, label): (Option<Provider>, SharedString) = match &current {
            None => (None, "Follow open thread".into()),
            Some(key) => match catalog.find_key(key) {
                Some(model) => (Some(model.provider), model.name.clone().into()),
                None => (
                    key.split_once(':').and_then(|(provider, _)| Provider::from_key(provider)),
                    key.split_once(':').map(|(_, id)| id).unwrap_or(key).to_string().into(),
                ),
            },
        };
        let claude: Vec<(String, String, bool)> =
            catalog.claude.iter().map(|model| (model.key(), model.name.clone(), model.legacy)).collect();
        let pi: Vec<(String, String, String)> =
            catalog.pi.iter().map(|model| (model.key(), model.name.clone(), model.detail.clone())).collect();
        let pi_status = catalog.pi_status.clone();
        let view = cx.weak_entity();
        let p = metal::palette(cx);

        MetalButton::new("default-model")
            .compact()
            .map(|this| match icon {
                Some(provider) => this.icon(pickers::provider_icon(provider, px(13.), p)),
                None => this,
            })
            .label(label)
            .caret()
            .max_w(px(280.))
            .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                let pick = |key: Option<String>| {
                    let view = view.clone();
                    move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                        let key = key.clone();
                        edit(&view, window, cx, move |settings| settings.default_model = key);
                    }
                };
                menu = menu
                    .min_w(px(260.))
                    .max_h(px(440.))
                    .scrollable(true)
                    .item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
                    .separator()
                    .label("Claude");
                if claude.is_empty() {
                    menu = menu.item(PopupMenuItem::new("Listed once a Claude thread has started").disabled(true));
                }
                for (key, name, legacy) in claude.clone() {
                    let label = if legacy { format!("{name} (legacy)") } else { name };
                    let checked = current.as_deref() == Some(key.as_str());
                    menu = menu.item(PopupMenuItem::new(label).checked(checked).on_click(pick(Some(key))));
                }
                menu = menu.separator().label("Pi");
                if pi.is_empty() {
                    let note = match &pi_status {
                        PiStatus::Loading => "Asking Pi for its models…",
                        PiStatus::Missing => "Pi isn't installed",
                        PiStatus::Failed(_) => "Pi didn't list its models",
                        PiStatus::Ready => "No models configured",
                    };
                    menu = menu.item(PopupMenuItem::new(note).disabled(true));
                }
                for (key, name, detail) in pi.clone() {
                    let checked = current.as_deref() == Some(key.as_str());
                    menu = menu
                        .item(PopupMenuItem::new(format!("{name} · {detail}")).checked(checked).on_click(pick(Some(key))));
                }
                menu
            })
            .into_any_element()
    }

    fn reasoning_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
        let current = saved.default_reasoning.as_deref().and_then(Reasoning::from_key);
        let view = cx.weak_entity();
        let mut choices = vec![Reasoning::Default];
        choices.extend(
            [Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max].map(Reasoning::Level),
        );
        choices.extend([Reasoning::Ultracode, Reasoning::Ultrathink]);

        MetalButton::new("default-reasoning")
            .compact()
            .icon(Lucide::Brain)
            .label(current.map(Reasoning::label).unwrap_or("Follow open thread"))
            .caret()
            .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                let pick = |reasoning: Option<Reasoning>| {
                    let view = view.clone();
                    move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                        edit(&view, window, cx, move |settings| {
                            settings.default_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
                        });
                    }
                };
                menu = menu
                    .min_w(px(220.))
                    .item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
                    .separator();
                for reasoning in choices.clone() {
                    if reasoning == Reasoning::Ultracode {
                        menu = menu.separator().label("Claude only");
                    }
                    menu = menu.item(
                        PopupMenuItem::new(reasoning.label())
                            .checked(current == Some(reasoning))
                            .on_click(pick(Some(reasoning))),
                    );
                }
                menu
            })
            .into_any_element()
    }

    fn access_control(&self, saved: &Settings, cx: &mut Context<Self>) -> AnyElement {
        let current = saved.default_access.as_deref().and_then(PermissionMode::from_cli);
        let view = cx.weak_entity();
        MetalButton::new("default-access")
            .compact()
            .map(|this| match current {
                Some(mode) => this.icon(pickers::access_icon(mode)),
                None => this,
            })
            .label(current.map(PermissionMode::label).unwrap_or("Follow open thread"))
            .caret()
            .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                let pick = |mode: Option<PermissionMode>| {
                    let view = view.clone();
                    move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                        edit(&view, window, cx, move |settings| {
                            settings.default_access = mode.map(|mode| mode.as_cli().to_string())
                        });
                    }
                };
                menu = menu
                    .min_w(px(220.))
                    .item(PopupMenuItem::new("Follow open thread").checked(current.is_none()).on_click(pick(None)))
                    .separator();
                for mode in PermissionMode::ALL {
                    menu = menu.item(
                        PopupMenuItem::new(mode.label())
                            .icon(pickers::access_icon(mode))
                            .checked(current == Some(mode))
                            .on_click(pick(Some(mode))),
                    );
                }
                menu
            })
            .into_any_element()
    }

    fn editor_control(&self, cx: &mut Context<Self>) -> AnyElement {
        let Some(workspace) = self.workspace.upgrade() else {
            return div().into_any_element();
        };
        let (names, current) = {
            let workspace = workspace.read(cx);
            (workspace.editor_names(), workspace.editor())
        };
        let Some(label) = names.get(current).copied() else {
            let p = metal::palette(cx);
            return div().text_size(px(13.)).text_color(p.ink3).child("No editor found").into_any_element();
        };
        let weak = self.workspace.clone();
        let view = cx.weak_entity();
        MetalButton::new("default-editor")
            .compact()
            .icon(Lucide::Code)
            .label(label)
            .caret()
            .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                menu = menu.min_w(px(200.));
                for (ix, name) in names.iter().enumerate() {
                    let weak = weak.clone();
                    let view = view.clone();
                    menu = menu.item(PopupMenuItem::new(*name).checked(ix == current).on_click(move |_, _, cx| {
                        let _ = weak.update(cx, |workspace, cx| workspace.set_editor(ix, cx));
                        let _ = view.update(cx, |_, cx| cx.notify());
                    }));
                }
                menu
            })
            .into_any_element()
    }

    fn render_appearance(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let current = metal::colorway(cx);
        let view = cx.weak_entity();
        let compact = settings::get(|settings| settings.compact_threads);

        let swatches = h_flex().p(px(18.)).gap(px(16.)).children(Colorway::ALL.into_iter().map(|colorway| {
            let selected = colorway == current;
            let view = view.clone();
            let preview = match colorway {
                Colorway::Bone => colorway_preview(&BONE).flex_1(),
                Colorway::Graphite => colorway_preview(&GRAPHITE).flex_1(),
                Colorway::Auto => h_flex()
                    .flex_1()
                    .h_full()
                    .child(colorway_preview(&BONE).flex_1().rounded_r(px(0.)))
                    .child(colorway_preview(&GRAPHITE).flex_1().rounded_l(px(0.))),
            };
            let mut ring = vec![BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.))];
            if selected {
                ring = vec![BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.))];
            }
            v_flex()
                .id(SharedString::from(format!("colorway-{}", colorway.label())))
                .flex_1()
                .gap(px(10.))
                .cursor_pointer()
                .on_click(move |_, window, cx| {
                    metal::set_colorway(colorway, window, cx);
                    let _ = view.update(cx, |_, cx| cx.notify());
                })
                .child(h_flex().h(px(96.)).rounded(px(14.)).shadow(ring).child(preview))
                .child(
                    h_flex()
                        .gap(px(8.))
                        .child(radio(selected, p))
                        .child(
                            div()
                                .text_size(px(13.5))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink)
                                .child(colorway.label()),
                        )
                        .when(colorway == Colorway::Auto, |this| {
                            this.child(div().text_size(px(12.)).text_color(p.ink3).child("Follows the desktop"))
                        }),
                )
        }));

        let colorways = section(
            "Colorway",
            Some("MetalUI's two finishes: warm Bone and dark Graphite."),
            vec![swatches.into_any_element()],
            p,
        );

        let sidebar = section(
            "Sidebar",
            None,
            vec![row(
                "Thread cards",
                "Comfortable cards show the project, branch and agent. Compact ones fit a thread on one line.",
                metal::segmented(
                    "thread-density",
                    &[(false, "Comfortable"), (true, "Compact")],
                    compact,
                    p,
                    move |compact, window, cx| edit(&view, window, cx, move |settings| settings.compact_threads = compact),
                ),
                p,
            )],
            p,
        );

        vec![colorways, sidebar]
    }

    fn render_providers(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let catalog = Catalog::global(cx);
        let account = catalog.claude_account.clone();
        let claude_models = catalog.claude.iter().filter(|model| !model.legacy).count();
        let pi_status = catalog.pi_status.clone();
        let pi_models = catalog.pi.len();

        let claude = section(
            "Claude Code",
            Some("Threads talk to the claude CLI over its stream-json protocol."),
            vec![
                row(
                    "Account",
                    match &account {
                        Some(account) => format!("Signed in as {account}."),
                        None => "Shown once a Claude thread has started.".to_string(),
                    },
                    metal::led("claude-led", if account.is_some() { Led::Live } else { Led::Off }, false, p),
                    p,
                ),
                row(
                    "Models",
                    if claude_models > 0 {
                        format!("{claude_models} current models, plus legacy ones in the picker.")
                    } else {
                        "Listed once a Claude thread has started.".to_string()
                    },
                    div(),
                    p,
                ),
                row(
                    "Binary",
                    binary_status(claude::find_claude_binary().as_deref(), "VECTRUM_CODE_CLAUDE_BIN"),
                    path_field(&self.claude_bin, window, cx),
                    p,
                ),
            ],
            p,
        );

        let (led, status) = match &pi_status {
            PiStatus::Loading => (Led::Waiting, "Asking Pi for its models…".to_string()),
            PiStatus::Ready if pi_models > 0 => (Led::Live, format!("{pi_models} models across your Pi providers.")),
            PiStatus::Ready => (Led::Waiting, "No models configured. Log in to a provider with `pi`.".to_string()),
            PiStatus::Missing => (
                Led::Off,
                "Not installed: npm install -g @earendil-works/pi-coding-agent, then log in with `pi`.".to_string(),
            ),
            PiStatus::Failed(error) => (Led::Failed, format!("Pi didn't list its models: {error}")),
        };
        let pi = section(
            "Pi",
            Some("Threads talk to pi --mode rpc. Its tools run without approval prompts."),
            vec![
                row(
                    "Models",
                    status,
                    h_flex().gap(px(10.)).child(metal::led("pi-led", led, pi_status == PiStatus::Loading, p)).child(
                        MetalButton::new("refresh-pi")
                            .compact()
                            .icon(IconName::RefreshCw)
                            .label("Refresh")
                            .disabled(pi_status == PiStatus::Loading)
                            .on_click(|_, _, cx| Catalog::refresh_pi(cx)),
                    ),
                    p,
                ),
                row(
                    "Binary",
                    binary_status(claude::find_pi_binary().as_deref(), "VECTRUM_CODE_PI_BIN"),
                    path_field(&self.pi_bin, window, cx),
                    p,
                ),
            ],
            p,
        );

        vec![claude, pi]
    }

    fn render_keybindings(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
        let shortcut = |label: &'static str, keys: &[&'static str]| {
            h_flex()
                .h(px(44.))
                .px(px(18.))
                .gap(px(16.))
                .child(div().flex_1().text_size(px(13.5)).text_color(p.ink).child(label))
                .child(metal::shortcut(keys, p))
                .into_any_element()
        };
        let conversation = section(
            "Conversation",
            None,
            vec![
                shortcut("Send message", if ctrl_enter { &["Ctrl", "Enter"] } else { &["Enter"] }),
                shortcut("New line", if ctrl_enter { &["Enter"] } else { &["Shift", "Enter"] }),
                shortcut("Interrupt the agent", &["Esc"]),
                shortcut("Pick one of the first nine models", &["Ctrl", "1–9"]),
            ],
            p,
        );
        let app = section(
            "Workspace",
            None,
            vec![
                shortcut("New thread", &["Ctrl", "N"]),
                shortcut("Add a project folder", &["Ctrl", "O"]),
                shortcut("Search threads", &["Ctrl", "K"]),
                shortcut("Show or hide the sidebar", &["Ctrl", "B"]),
                shortcut("Show or hide the project panel", &["Ctrl", "Shift", "B"]),
                shortcut("Open or close settings", &["Ctrl", ","]),
                shortcut("Quit", &["Ctrl", "Q"]),
            ],
            p,
        );
        vec![conversation, app]
    }

    fn render_archive(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let threads = self
            .workspace
            .upgrade()
            .map(|workspace| workspace.read(cx).settled_threads(cx))
            .unwrap_or_default();
        if threads.is_empty() {
            return vec![section(
                "Settled threads",
                None,
                vec![
                    div()
                        .px(px(18.))
                        .py(px(22.))
                        .text_size(px(13.))
                        .text_color(p.ink3)
                        .child("Nothing settled. Settle a thread from its card to file it away here.")
                        .into_any_element(),
                ],
                p,
            )];
        }

        let all: Vec<Entity<Session>> = threads.iter().map(|thread| thread.session.clone()).collect();
        let rows = threads
            .into_iter()
            .enumerate()
            .map(|(ix, thread)| {
                let restore = thread.session.clone();
                let forget = thread.session.clone();
                let restore_ws = self.workspace.clone();
                let forget_ws = self.workspace.clone();
                h_flex()
                    .px(px(18.))
                    .py(px(12.))
                    .gap(px(12.))
                    .child(metal::badge(thread.initials, thread.tint, p))
                    .child(
                        v_flex()
                            .flex_1()
                            .min_w_0()
                            .gap(px(2.))
                            .child(
                                div()
                                    .truncate()
                                    .text_size(px(13.5))
                                    .font_weight(FontWeight::MEDIUM)
                                    .text_color(p.ink)
                                    .child(thread.title),
                            )
                            .child(
                                h_flex()
                                    .gap(px(6.))
                                    .text_size(px(12.))
                                    .text_color(p.ink3)
                                    .child(pickers::provider_icon(thread.provider, px(11.), p))
                                    .child(match thread.age.as_str() {
                                        "now" => format!("{} · just now", thread.project),
                                        age => format!("{} · {age} ago", thread.project),
                                    }),
                            ),
                    )
                    .child(
                        MetalButton::new(("restore-thread", ix))
                            .compact()
                            .icon(Lucide::ArchiveRestore)
                            .label("Restore")
                            .tooltip("Move it back to the thread list and open it")
                            .on_click(move |_, window, cx| {
                                let session = restore.clone();
                                let _ = restore_ws.update(cx, |workspace, cx| workspace.restore_thread(session, window, cx));
                            }),
                    )
                    .child(
                        MetalButton::new(("delete-settled", ix))
                            .ghost()
                            .mini()
                            .icon(Lucide::Trash)
                            .tooltip("Delete thread")
                            .on_click(move |_, window, cx| {
                                let session = forget.clone();
                                let _ = forget_ws.update(cx, |workspace, cx| workspace.forget_thread(session, window, cx));
                            }),
                    )
                    .into_any_element()
            })
            .collect();

        let workspace = self.workspace.clone();
        let view = cx.weak_entity();
        vec![
            v_flex()
                .gap(px(10.))
                .child(
                    h_flex()
                        .px(px(4.))
                        .gap(px(8.))
                        .child(section_title("Settled threads", p))
                        .child(div().flex_1())
                        .child(
                            MetalButton::new("delete-all-settled")
                                .ghost()
                                .compact()
                                .icon(Lucide::Trash)
                                .label("Delete all")
                                .on_click(move |_, window, cx| {
                                    for session in all.clone() {
                                        let _ = workspace
                                            .update(cx, |workspace, cx| workspace.forget_thread(session, window, cx));
                                    }
                                    let _ = view.update(cx, |_, cx| cx.notify());
                                }),
                        ),
                )
                .child(
                    div()
                        .px(px(4.))
                        .text_size(px(12.5))
                        .text_color(p.ink3)
                        .child("Settled threads keep their transcript; restoring one resumes the conversation with the next message."),
                )
                .child(card(rows, p))
                .into_any_element(),
        ]
    }

    fn render_about(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let settings_file = settings::path();
        let scratch = workspace::scratch_dir();
        let open_button = |id: &'static str, path: Option<std::path::PathBuf>| {
            MetalButton::new(id)
                .compact()
                .icon(IconName::FolderOpen)
                .label("Open")
                .disabled(path.is_none())
                .on_click(move |_, _, _| {
                    if let Some(path) = &path {
                        open_folder(path);
                    }
                })
        };
        let hero = h_flex()
            .px(px(4.))
            .gap(px(16.))
            .child(
                div()
                    .flex()
                    .items_center()
                    .justify_center()
                    .size(px(56.))
                    .rounded(px(16.))
                    .raised(p)
                    .child(Icon::default().data(metal::LOGO_SVG).size(px(28.)).text_color(p.ink)),
            )
            .child(
                v_flex()
                    .gap(px(4.))
                    .child(
                        h_flex()
                            .gap(px(8.))
                            .child(div().text_size(px(22.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum Code"))
                            .child(metal::tag(format!("v{}", env!("CARGO_PKG_VERSION")), p)),
                    )
                    .child(
                        div()
                            .text_size(px(13.))
                            .text_color(p.ink3)
                            .child("A T3 Code-style desktop client for Claude Code and Pi, built with GPUI."),
                    ),
            );
        let files = section(
            "Files",
            None,
            vec![
                row(
                    "Settings",
                    settings_file.as_deref().map(display_path).unwrap_or_else(|| "Unavailable".into()),
                    open_button("open-settings-dir", settings_file.as_ref().and_then(|path| path.parent()).map(Path::to_path_buf)),
                    p,
                ),
                row(
                    "Threads without a project",
                    scratch.as_deref().map(display_path).unwrap_or_else(|| "Unavailable".into()),
                    open_button("open-scratch-dir", scratch.clone()),
                    p,
                ),
            ],
            p,
        );
        vec![hero.into_any_element(), files]
    }
}

impl Focusable for SettingsView {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl Render for SettingsView {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let page = self.render_page(window, cx);
        h_flex()
            .key_context("SettingsView")
            .track_focus(&self.focus_handle)
            .size_full()
            .on_action(cx.listener(|this, _: &CloseSettings, _, cx| this.close(cx)))
            .child(self.render_nav(cx))
            .child(
                div().flex_1().min_w_0().h_full().py(px(SHEET_INSET)).pr(px(SHEET_INSET)).child(
                    v_flex()
                        .size_full()
                        .rounded(px(SHEET_RADIUS))
                        .plate(p)
                        .child(self.render_top_bar(cx))
                        .child(
                            div().id(("settings-scroll", self.page as usize)).flex_1().min_h_0().overflow_y_scroll().child(
                                h_flex().w_full().justify_center().px(px(32.)).py(px(28.)).child(
                                    v_flex().w_full().max_w(px(PAGE_WIDTH)).gap(px(30.)).children(page),
                                ),
                            ),
                        ),
                ),
            )
    }
}

// ---- building blocks ---------------------------------------------------------

/// Saves an edit, then repaints the view and tells the conversations.
fn edit(view: &WeakEntity<SettingsView>, window: &mut Window, cx: &mut App, change: impl FnOnce(&mut Settings)) {
    settings::update(change);
    let _ = view.update(cx, |view, cx| view.changed(window, cx));
}

fn switch(
    id: &'static str,
    on: bool,
    view: &WeakEntity<SettingsView>,
    p: &Palette,
    set: impl Fn(&mut Settings, bool) + 'static,
) -> Stateful<Div> {
    let view = view.clone();
    metal::toggle(id, on, p, move |on, window, cx| edit(&view, window, cx, |settings| set(settings, on)))
}

fn section_title(title: &'static str, p: &Palette) -> Div {
    div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(title)
}

/// A raised card of rows, with hairlines between them.
fn card(rows: Vec<AnyElement>, p: &Palette) -> Div {
    let count = rows.len();
    v_flex().rounded(px(18.)).raised(p).children(rows.into_iter().enumerate().flat_map(|(ix, row)| {
        let rule = (ix + 1 < count).then(|| div().px(px(18.)).child(metal::rule(p)).into_any_element());
        std::iter::once(row).chain(rule)
    }))
}

fn section(title: &'static str, description: Option<&'static str>, rows: Vec<AnyElement>, p: &Palette) -> AnyElement {
    v_flex()
        .gap(px(10.))
        .child(
            v_flex()
                .px(px(4.))
                .gap(px(3.))
                .child(section_title(title, p))
                .when_some(description, |this, description| {
                    this.child(div().text_size(px(12.5)).text_color(p.ink3).child(description))
                }),
        )
        .child(card(rows, p))
        .into_any_element()
}

/// One setting: its name and what it does on the left, the control on the right.
fn row(
    title: &'static str,
    description: impl Into<SharedString>,
    control: impl IntoElement,
    p: &Palette,
) -> AnyElement {
    h_flex()
        .px(px(18.))
        .py(px(14.))
        .gap(px(24.))
        .child(
            v_flex()
                .flex_1()
                .min_w_0()
                .gap(px(3.))
                .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title))
                .child(div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(description.into())),
        )
        .child(div().flex_none().child(control))
        .into_any_element()
}

/// A path field, sunk into a well.
fn path_field(input: &Entity<InputState>, _: &mut Window, cx: &App) -> Div {
    let p = metal::palette(cx);
    h_flex()
        .w(px(260.))
        .h(px(32.))
        .px(px(11.))
        .rounded(px(12.))
        .well(p)
        .font_family(metal::MONO)
        .text_size(px(12.))
        .text_color(p.ink)
        .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small()))
}

/// Where a binary was found, and what overrides the field.
fn binary_status(found: Option<&Path>, env: &str) -> String {
    let found = match found {
        Some(path) if claude::is_executable(path) => format!("Using {}.", display_path(path)),
        Some(path) => format!("{} isn't an executable file.", display_path(path)),
        None => "Not found on PATH or in the usual install folders.".to_string(),
    };
    if std::env::var_os(env).is_some() {
        format!("{found} Set by {env}, which wins over this field.")
    } else {
        found
    }
}

/// A radio dot: a well, with a raised green bead when picked.
fn radio(selected: bool, p: &Palette) -> Div {
    div()
        .flex()
        .flex_none()
        .items_center()
        .justify_center()
        .size(px(16.))
        .rounded_full()
        .well(p)
        .when(selected, |this| {
            this.child(div().size(px(8.)).rounded_full().bg(p.green_deep))
        })
}

/// A miniature of the app in a colorway: the sheet on the table, a thread
/// list and a composer.
fn colorway_preview(c: &Palette) -> Div {
    let line = |width: f32, color: Hsla| div().h(px(4.)).w(px(width)).rounded_full().bg(color);
    h_flex()
        .h_full()
        .overflow_hidden()
        .rounded(px(14.))
        .bg(c.page)
        .p(px(8.))
        .gap(px(8.))
        .child(
            v_flex()
                .w(px(26.))
                .gap(px(6.))
                .pt(px(4.))
                .child(line(22., c.ink2.opacity(0.6)))
                .child(line(16., c.ink3.opacity(0.5)))
                .child(line(20., c.ink3.opacity(0.5))),
        )
        .child(
            v_flex()
                .flex_1()
                .h_full()
                .p(px(8.))
                .gap(px(6.))
                .rounded(px(10.))
                .bg(metal::vertical((c.s_hi, c.s)))
                .shadow(c.raise_sm.clone())
                .child(line(40., c.ink.opacity(0.7)))
                .child(line(56., c.ink3.opacity(0.5)))
                .child(div().flex_1())
                .child(
                    h_flex()
                        .h(px(16.))
                        .px(px(4.))
                        .rounded(px(6.))
                        .bg(metal::vertical(c.row_on))
                        .shadow(c.raise_sm.clone())
                        .child(div().flex_1())
                        .child(div().size(px(8.)).rounded_full().bg(metal::vertical(c.primary))),
                ),
        )
}

fn open_folder(path: &Path) {
    let Some(program) = workspace::find_program(&["xdg-open"]) else { return };
    let spawned = Command::new(program)
        .arg(path)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn();
    if let Ok(mut child) = spawned {
        std::thread::spawn(move || child.wait());
    }
}