Vectrum
Sign In
2069 lines · 83.9 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},
    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::session::{Reasoning, Session};
use crate::settings::{
    self, BackgroundActivity, EnvVar, FollowUp, ProjectOrder, ProviderInstance, QuitShortcut, Settings, Streaming,
    Submodules, TimeFormat, WorkspaceMode,
};
use crate::ui::chat::display_path;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers;
use crate::ui::themes;
use crate::ui::workspace::{self, SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};

const PAGE_WIDTH: f32 = 760.;

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

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

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

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

pub enum SettingsEvent {
    Close,
}

/// The fields of the provider instance open on the Providers page.
struct ProviderForm {
    id: String,
    name: Entity<InputState>,
    binary: Entity<InputState>,
    home: Entity<InputState>,
    args: Entity<InputState>,
    env: Vec<(Entity<InputState>, Entity<InputState>)>,
    _subscriptions: Vec<Subscription>,
}

pub struct SettingsView {
    workspace: WeakEntity<Workspace>,
    page: Page,
    add_project_dir: Entity<InputState>,
    provider_form: Option<ProviderForm>,
    /// Instances an update is running for.
    updating: Vec<String>,
    focus_handle: FocusHandle,
    _subscriptions: Vec<Subscription>,
}

impl EventEmitter<SettingsEvent> for SettingsView {}

const WORKSPACES: &[(WorkspaceMode, &str)] = &[(WorkspaceMode::Local, "Local"), (WorkspaceMode::Worktree, "New worktree")];
const SUBMODULES: &[(Submodules, &str)] =
    &[(Submodules::Skip, "Skip"), (Submodules::TopLevel, "Top level"), (Submodules::Recursive, "Recursive")];
const PROJECT_ORDERS: &[(ProjectOrder, &str)] = &[
    (ProjectOrder::LastMessage, "Last user message"),
    (ProjectOrder::Name, "Name"),
    (ProjectOrder::Added, "Date added"),
];
const QUIT_SHORTCUTS: &[(QuitShortcut, &str)] = &[(QuitShortcut::Press, "Press"), (QuitShortcut::Hold, "Hold")];
const NOTIFICATIONS: &[((bool, bool), &str)] =
    &[((false, false), "Off"), ((true, false), "Notifications only"), ((true, true), "Notifications and sound")];
const TIME_FORMATS: &[(TimeFormat, &str)] =
    &[(TimeFormat::System, "System default"), (TimeFormat::Hour12, "12-hour"), (TimeFormat::Hour24, "24-hour")];
const STREAMING: &[(Streaming, &str)] =
    &[(Streaming::Tokens, "Stream tokens"), (Streaming::Paragraphs, "Show finished paragraphs")];
const DIFF_STATES: &[(bool, &str)] = &[(false, "Expanded"), (true, "Collapsed")];
const DIFF_LAYOUTS: &[(bool, &str)] = &[(false, "Stacked"), (true, "Side by side")];
const SEND_KEYS: &[(bool, &str)] = &[(false, "Enter"), (true, "Ctrl+Enter")];
const FOLLOW_UPS: &[(FollowUp, &str)] = &[(FollowUp::Steer, "Steer"), (FollowUp::Queue, "Queue")];
const BACKGROUND: &[(BackgroundActivity, &str)] = &[
    (BackgroundActivity::Always, "Always on"),
    (BackgroundActivity::Balanced, "Balanced"),
    (BackgroundActivity::Minimal, "Minimal"),
];

impl SettingsView {
    pub fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let saved = settings::load();
        let add_project_dir = cx.new(|cx| {
            InputState::new(window, cx).placeholder("~/").default_value(saved.add_project_dir.clone())
        });
        let subscriptions = vec![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,
            updating: Vec::new(),
            focus_handle: cx.focus_handle(),
            _subscriptions: subscriptions,
        }
    }

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

    fn reload_inputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let dir = settings::get(|settings| settings.add_project_dir.clone());
        self.add_project_dir.update(cx, |input, cx| input.set_value(dir, window, cx));
        let selected = self.provider_form.as_ref().map(|form| form.id.clone());
        self.provider_form = None;
        if let Some(id) = selected {
            self.select_provider(&id, window, cx);
        }
    }

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

    fn restore_defaults(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        settings::restore_defaults();
        metal::sync_appearance(window, cx);
        self.reload_inputs(window, cx);
        self.changed(window, cx);
    }

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

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

    fn render_nav(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let settled = self
            .workspace
            .upgrade()
            .map(|workspace| workspace.read(cx).settled_threads(cx).len())
            .unwrap_or(0);
        let updates = Catalog::global(cx).instances.iter().filter(|state| state.update_available()).count();

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

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

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

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

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

        let model_pick = {
            let view = view.clone();
            move |key: Option<String>, window: &mut Window, cx: &mut App| {
                edit(&view, window, cx, move |settings| settings.default_model = key)
            }
        };
        let reasoning_pick = {
            let view = view.clone();
            move |reasoning: Option<Reasoning>, window: &mut Window, cx: &mut App| {
                edit(&view, window, cx, move |settings| {
                    settings.default_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
                })
            }
        };
        let new_threads = section(
            "New threads",
            None,
            vec![
                row(
                    "Model",
                    "Default model for new threads. Following the open thread keeps whatever you picked last.",
                    h_flex()
                        .gap(px(6.))
                        .child(model_control("default-model", saved.default_model.clone(), "Follow open thread", model_pick, cx))
                        .child(reasoning_control(
                            "default-reasoning",
                            saved.default_reasoning.as_deref().and_then(Reasoning::from_key),
                            "Follow open thread",
                            reasoning_pick,
                        )),
                    p,
                ),
                row(
                    "Permissions",
                    "Default permissions for new threads. Pi threads always run with full access.",
                    self.access_control(&saved, cx),
                    p,
                ),
                row(
                    "Workspace",
                    "Where new threads start: the project folder, or a git worktree and branch of their own. The start screen can switch it per thread.",
                    choice("workspace", saved.workspace, WORKSPACES, &view, |settings, mode| settings.workspace = mode),
                    p,
                ),
                row(
                    "Submodules",
                    "How new worktrees populate git submodules.",
                    choice("submodules", saved.submodules, SUBMODULES, &view, |settings, mode| settings.submodules = mode),
                    p,
                ),
            ],
            p,
        );

        let text_pick = {
            let view = view.clone();
            move |key: Option<String>, window: &mut Window, cx: &mut App| {
                edit(&view, window, cx, move |settings| settings.text_model = key.unwrap_or_default())
            }
        };
        let text_reasoning_pick = {
            let view = view.clone();
            move |reasoning: Option<Reasoning>, window: &mut Window, cx: &mut App| {
                edit(&view, window, cx, move |settings| {
                    settings.text_reasoning = reasoning.map(|reasoning| reasoning.key().to_string())
                })
            }
        };
        let text_model = Some(saved.text_model.clone()).filter(|key| !key.is_empty());
        let text_generation = section(
            "Text generation",
            None,
            vec![row(
                "Text generation model",
                "Writes thread titles from the first message. Off keeps the message's first line.",
                h_flex()
                    .gap(px(6.))
                    .child(model_control("text-model", text_model.clone(), "Off", text_pick, cx))
                    .when(text_model.is_some(), |this| {
                        this.child(reasoning_control(
                            "text-reasoning",
                            saved.text_reasoning.as_deref().and_then(Reasoning::from_key),
                            "Default",
                            text_reasoning_pick,
                        ))
                    }),
                p,
            )],
            p,
        );

        let projects = section(
            "Projects & threads",
            None,
            vec![
                row(
                    "Start from origin",
                    "Creates worktrees from the latest matching branch on origin instead of your local branch.",
                    switch("start-from-origin", saved.start_from_origin, &view, p, |settings, on| {
                        settings.start_from_origin = on
                    }),
                    p,
                ),
                row(
                    "Add project starts in",
                    "Leave empty to use \u{201c}~/\u{201d} when the Add project browser opens.",
                    text_field(&self.add_project_dir, 260., window, cx),
                    p,
                ),
            ],
            p,
        );

        let organization = section(
            "Organization",
            None,
            vec![
                row(
                    "Project grouping",
                    "Group sidebar threads under their project, worktree threads included.",
                    switch("group-by-project", saved.group_by_project, &view, p, |settings, on| {
                        settings.group_by_project = on
                    }),
                    p,
                ),
                row(
                    "Project order",
                    "Order of projects in the sidebar and the project menus.",
                    choice("project-order", saved.project_order, PROJECT_ORDERS, &view, |settings, order| {
                        settings.project_order = order
                    }),
                    p,
                ),
                row(
                    "Auto-resume limited threads",
                    "Continue a thread a usage limit stopped once the limit resets. Each thread can cancel its scheduled continuation.",
                    switch("auto-resume", saved.auto_resume_limited, &view, p, |settings, on| {
                        settings.auto_resume_limited = on
                    }),
                    p,
                ),
                row(
                    "Snooze limited threads",
                    "Snooze threads a usage limit stopped until the limit resets. Combine with auto-resume to continue when they wake.",
                    switch("snooze-limited", saved.snooze_limited, &view, p, |settings, on| settings.snooze_limited = on),
                    p,
                ),
                row(
                    "Working section (beta)",
                    "Fold working threads into a Working section. They return to the top of the list when they need you.",
                    switch("working-section", saved.working_section, &view, p, |settings, on| {
                        settings.working_section = on
                    }),
                    p,
                ),
                row(
                    "Auto-settle merged threads",
                    "Settle a worktree thread when its pull request merges (checked with GitHub's gh). Closed pull requests settle either way.",
                    switch("auto-settle-merged", saved.auto_settle_merged, &view, p, |settings, on| {
                        settings.auto_settle_merged = on
                    }),
                    p,
                ),
                row(
                    "Auto-settle inactive threads",
                    "Threads with no activity for this long settle on their own. Pinned threads stay.",
                    switch("auto-settle-inactive", saved.auto_settle_inactive, &view, p, |settings, on| {
                        settings.auto_settle_inactive = on
                    }),
                    p,
                ),
                row(
                    "Days of inactivity before auto-settle",
                    "Any new message un-settles a thread.",
                    div().when(!saved.auto_settle_inactive, |this| this.opacity(0.45)).child(stepper(
                        "inactive-days",
                        u64::from(saved.inactive_days),
                        1,
                        (1, 90),
                        "days",
                        &view,
                        p,
                        |settings, days| settings.inactive_days = days as u32,
                    )),
                    p,
                ),
            ],
            p,
        );

        let confirmations = section(
            "Confirmations",
            None,
            vec![
                row(
                    "Unpin confirmation",
                    "Ask before unpinning a thread.",
                    switch("confirm-unpin", saved.confirm_unpin, &view, p, |settings, on| settings.confirm_unpin = on),
                    p,
                ),
                row(
                    "Settle confirmation",
                    "Require a second click on a thread card's settle button before the thread is settled.",
                    switch("confirm-settle", saved.confirm_settle, &view, p, |settings, on| settings.confirm_settle = on),
                    p,
                ),
                row(
                    "Delete confirmation",
                    "Ask before deleting a thread and its chat history.",
                    switch("confirm-delete", saved.confirm_delete, &view, p, |settings, on| settings.confirm_delete = on),
                    p,
                ),
                row(
                    "Quit shortcut",
                    "Press quits on Ctrl+Q. Hold waits for the keys to be held, and also quits on two quick presses.",
                    choice("quit-shortcut", saved.quit_shortcut, QUIT_SHORTCUTS, &view, |settings, shortcut| {
                        settings.quit_shortcut = shortcut
                    }),
                    p,
                ),
            ],
            p,
        );

        vec![new_threads, text_generation, projects, organization, confirmations]
    }

    fn render_behavior(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let saved = settings::load();
        let view = cx.weak_entity();
        let can_notify = workspace::find_program(&["notify-send"]).is_some();

        let notifications = section(
            "Notifications",
            None,
            vec![
                row(
                    "Thread notifications",
                    if can_notify {
                        "Desktop alerts when a thread finishes, fails, or needs input or approval while the window is in the background."
                    } else {
                        "Needs notify-send (libnotify), which isn't installed."
                    },
                    div().when(!can_notify, |this| this.opacity(0.45)).child(choice(
                        "thread-notifications",
                        (saved.notify && can_notify, saved.notify_sound),
                        NOTIFICATIONS,
                        &view,
                        move |settings, (notify, sound)| {
                            settings.notify = notify && can_notify;
                            settings.notify_sound = sound;
                        },
                    )),
                    p,
                ),
                row(
                    "In-app notifications",
                    "Show a toast when another thread finishes, fails, or needs input or approval while the app has focus.",
                    switch("in-app-notifications", saved.in_app_notifications, &view, p, |settings, on| {
                        settings.in_app_notifications = on
                    }),
                    p,
                ),
            ],
            p,
        );

        let conversation = section(
            "Conversation",
            None,
            vec![
                row(
                    "Time format",
                    "System default follows your locale.",
                    choice("time-format", saved.time_format, TIME_FORMATS, &view, |settings, format| {
                        settings.time_format = format
                    }),
                    p,
                ),
                row(
                    "Response streaming",
                    "Stream every token, or show each paragraph or code block as soon as it's complete.",
                    choice("streaming", saved.streaming, STREAMING, &view, |settings, streaming| {
                        settings.streaming = streaming
                    }),
                    p,
                ),
                row(
                    "Fold finished turns",
                    "Tuck a finished turn's tool calls and interim messages into one \u{201c}Worked for\u{201d} row.",
                    switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on),
                    p,
                ),
                row(
                    "Show costs",
                    "What each turn and thread cost, in the transcript and the project panel.",
                    switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on),
                    p,
                ),
                row(
                    "Proactive panels",
                    "Open the diff after a turn that edits at least 3 files or 50 lines.",
                    switch("proactive-panels", saved.proactive_panels, &view, p, |settings, on| {
                        settings.proactive_panels = on
                    }),
                    p,
                ),
            ],
            p,
        );

        let diffs = section(
            "Diffs",
            None,
            vec![
                row(
                    "Hide whitespace changes",
                    "Whether the diff view ignores whitespace-only edits by default.",
                    switch("hide-whitespace", saved.hide_whitespace, &view, p, |settings, on| {
                        settings.hide_whitespace = on
                    }),
                    p,
                ),
                row(
                    "Default diff file state",
                    "Start with files expanded or collapsed when opening a diff.",
                    choice("diff-collapsed", saved.diff_collapsed, DIFF_STATES, &view, |settings, collapsed| {
                        settings.diff_collapsed = collapsed
                    }),
                    p,
                ),
                row(
                    "Diff layout",
                    "Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.",
                    choice("diff-layout", saved.diff_side_by_side, DIFF_LAYOUTS, &view, |settings, side_by_side| {
                        settings.diff_side_by_side = side_by_side
                    }),
                    p,
                ),
            ],
            p,
        );

        let composer = section(
            "Composer",
            None,
            vec![
                row(
                    "Show skills in slash menu",
                    "Also list skills in the / command menu. Skills always appear when you type $.",
                    switch("slash-skills", saved.slash_skills, &view, p, |settings, on| settings.slash_skills = on),
                    p,
                ),
                row(
                    "Rich text composer",
                    "Show formatted Markdown as you type.",
                    switch("rich-composer", saved.rich_composer, &view, p, |settings, on| settings.rich_composer = on),
                    p,
                ),
                row(
                    "Collapse composer on scroll",
                    "Rest the composer of a conversation on one line while you scroll it. Click it or start typing to expand it again.",
                    switch("collapse-composer", saved.collapse_composer, &view, p, |settings, on| {
                        settings.collapse_composer = on
                    }),
                    p,
                ),
                row(
                    "Send shortcut",
                    "Which keys send a message; the other starts a new line.",
                    choice("send-shortcut", saved.send_with_ctrl_enter, SEND_KEYS, &view, |settings, ctrl| {
                        settings.send_with_ctrl_enter = ctrl
                    }),
                    p,
                ),
                row(
                    "Follow-up behavior",
                    "Steer the running turn with messages sent while the agent works, or queue them for after it. Ctrl+Enter (Ctrl+Shift+Enter when Ctrl+Enter sends) does the opposite for one message.",
                    choice("follow-up", saved.follow_up, FOLLOW_UPS, &view, |settings, follow_up| {
                        settings.follow_up = follow_up
                    }),
                    p,
                ),
            ],
            p,
        );

        let background = section(
            "Background",
            None,
            vec![
                row(
                    "Provider update checks",
                    "Check the installed agent CLIs for newer versions.",
                    switch_then("update-checks", saved.update_checks, &view, p, |settings, on| settings.update_checks = on, |cx| {
                        Catalog::refresh_all(cx)
                    }),
                    p,
                ),
                row(
                    "Continue threads after restarts",
                    "Automatically resume threads an update, crash, or restart interrupted.",
                    switch("continue-after-restart", saved.continue_after_restart, &view, p, |settings, on| {
                        settings.continue_after_restart = on
                    }),
                    p,
                ),
                row(
                    "Background activity",
                    "Balanced pauses health checks and git polling while the window has been away for a while or the machine saves power. Minimal pauses them whenever the window is in the background.",
                    choice("background-activity", saved.background_activity, BACKGROUND, &view, |settings, activity| {
                        settings.background_activity = activity
                    }),
                    p,
                ),
                row("Open in", "The editor the project panel opens projects in.", self.editor_control(cx), p),
            ],
            p,
        );

        vec![notifications, conversation, diffs, composer, background]
    }

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

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

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

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

        vec![sidebar]
    }

    fn render_themes(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let view = cx.weak_entity();
        themes::page(
            cx,
            Rc::new(move |_, cx| {
                let _ = view.update(cx, |_, cx| cx.notify());
            }),
        )
    }

    // ---- providers ---------------------------------------------------------

    /// Opens an instance's fields on the Providers page.
    fn select_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
        let Some(instance) = settings::get(|settings| settings.instance(id).cloned()) else {
            self.provider_form = None;
            cx.notify();
            return;
        };
        let input = |value: &str, placeholder: &str, window: &mut Window, cx: &mut Context<Self>| {
            let (value, placeholder) = (value.to_string(), placeholder.to_string());
            cx.new(|cx| InputState::new(window, cx).placeholder(placeholder).default_value(value))
        };
        let default_home = match instance.kind {
            Provider::Claude => "~/.claude",
            Provider::Pi => "~/.pi/agent",
        };
        let name = input(&instance.name, instance.kind.label(), window, cx);
        let binary = input(&instance.binary, "Look it up on PATH", window, cx);
        let home = input(&instance.home, default_home, window, cx);
        let args = input(&instance.args, "", window, cx);
        let env: Vec<_> = instance
            .env
            .iter()
            .map(|var| (input(&var.key, "NAME", window, cx), input(&var.value, "value", window, cx)))
            .collect();
        let mut subscriptions = Vec::new();
        for field in [&name, &binary, &home, &args].into_iter().chain(env.iter().flat_map(|(key, value)| [key, value])) {
            subscriptions.push(cx.subscribe_in(field, window, |this, _, event: &InputEvent, window, cx| {
                if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) {
                    this.save_provider_form(window, cx);
                }
            }));
        }
        self.provider_form =
            Some(ProviderForm { id: instance.id, name, binary, home, args, env, _subscriptions: subscriptions });
        cx.notify();
    }

    /// Saves the open instance's fields, if they changed.
    fn save_provider_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(form) = &self.provider_form else { return };
        let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
        let (name, binary, home, args) = (value(&form.name), value(&form.binary), value(&form.home), value(&form.args));
        let env: Vec<EnvVar> = form
            .env
            .iter()
            .map(|(key, val)| EnvVar { key: value(key), value: val.read(cx).value().to_string() })
            .collect();
        let id = form.id.clone();
        let unchanged = settings::get(|settings| {
            settings.instance(&id).is_some_and(|instance| {
                instance.name == name && instance.binary == binary && instance.home == home && instance.args == args && instance.env == env
            })
        });
        if unchanged {
            return;
        }
        settings::update(|settings| {
            if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) {
                instance.name = name;
                instance.binary = binary;
                instance.home = home;
                instance.args = args;
                instance.env = env;
            }
        });
        Catalog::sync_instances(cx);
        self.changed(window, cx);
    }

    fn set_provider_enabled(&mut self, id: &str, enabled: bool, window: &mut Window, cx: &mut Context<Self>) {
        settings::update(|settings| {
            if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) {
                instance.enabled = enabled;
            }
        });
        Catalog::sync_instances(cx);
        self.changed(window, cx);
    }

    fn add_provider(&mut self, kind: Provider, window: &mut Window, cx: &mut Context<Self>) {
        let ids: Vec<String> = settings::instances().into_iter().map(|instance| instance.id).collect();
        let id = (2..)
            .map(|n| format!("{}-{n}", kind.key()))
            .find(|id| !ids.contains(id))
            .unwrap_or_else(|| kind.key().to_string());
        let mut instance = ProviderInstance::new(kind, &id);
        let count = ids.iter().filter(|existing| existing.starts_with(kind.key())).count() + 1;
        instance.name = format!("{} {count}", kind.label());
        settings::update(|settings| settings.providers.push(instance));
        Catalog::sync_instances(cx);
        self.select_provider(&id, window, cx);
        self.changed(window, cx);
    }

    fn remove_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
        let id = id.to_string();
        settings::update(|settings| settings.providers.retain(|instance| instance.id != id));
        Catalog::sync_instances(cx);
        self.provider_form = None;
        if let Some(first) = settings::instances().first() {
            self.select_provider(&first.id.clone(), window, cx);
        }
        self.changed(window, cx);
    }

    fn add_env_var(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(id) = self.provider_form.as_ref().map(|form| form.id.clone()) else { return };
        settings::update(|settings| {
            if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id) {
                instance.env.push(EnvVar::default());
            }
        });
        self.select_provider(&id, window, cx);
    }

    fn remove_env_var(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let Some(id) = self.provider_form.as_ref().map(|form| form.id.clone()) else { return };
        self.save_provider_form(window, cx);
        settings::update(|settings| {
            if let Some(instance) = settings.providers.iter_mut().find(|instance| instance.id == id)
                && ix < instance.env.len()
            {
                instance.env.remove(ix);
            }
        });
        Catalog::sync_instances(cx);
        self.select_provider(&id, window, cx);
        self.changed(window, cx);
    }

    /// Runs the agent's own updater in the background, then checks it again.
    fn update_provider(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
        let Some(config) = settings::get(|settings| settings.instance(id).cloned()) else { return };
        let Some((mut command, description)) = models::update_command(&config) else { return };
        self.updating.push(id.to_string());
        cx.notify();
        let task = cx.background_executor().spawn(async move {
            command.stdin(Stdio::null()).output().map_err(|err| err.to_string()).and_then(|output| {
                if output.status.success() {
                    Ok(())
                } else {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    Err(stderr.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("failed").trim().to_string())
                }
            })
        });
        let id = id.to_string();
        cx.spawn_in(window, async move |this, cx| {
            let result = task.await;
            let _ = this.update_in(cx, |this, window, cx| {
                this.updating.retain(|updating| *updating != id);
                let note = match result {
                    Ok(()) => Notification::success(format!("Updated with {description}.")),
                    Err(err) => Notification::error(format!("{description} failed: {err}")),
                };
                window.push_notification(note, cx);
                Catalog::refresh(&id, cx);
                cx.notify();
            });
        })
        .detach();
    }

    fn render_providers(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let catalog = Catalog::global(cx);
        let instances: Vec<InstanceState> = catalog.instances.clone();
        let selected = self
            .provider_form
            .as_ref()
            .map(|form| form.id.clone())
            .or_else(|| instances.first().map(|state| state.id().to_string()));
        let checked = instances.iter().filter_map(|state| state.checked).max();
        let checking = instances.iter().any(|state| state.checking);
        let view = cx.weak_entity();

        let header = h_flex()
            .px(px(4.))
            .gap(px(6.))
            .child(section_title("Providers", p))
            .child(
                MetalButton::new("add-provider-header")
                    .ghost()
                    .mini()
                    .icon(Lucide::Plus)
                    .tooltip("Add a provider")
                    .dropdown_menu(add_provider_menu(view.clone())),
            )
            .child(div().flex_1())
            .child(
                MetalButton::new("refresh-providers")
                    .ghost()
                    .compact()
                    .icon(IconName::RefreshCw)
                    .label(match (checking, checked) {
                        (true, _) => "Checking…".to_string(),
                        (false, Some(checked)) => format!("Checked {}", ago(checked)),
                        (false, None) => "Check now".to_string(),
                    })
                    .disabled(checking)
                    .tooltip("Check versions, accounts and models again")
                    .on_click(|_, _, cx| Catalog::refresh_all(cx)),
            );

        let list = v_flex()
            .w(px(250.))
            .flex_none()
            .p(px(6.))
            .gap(px(2.))
            .children(instances.iter().map(|state| {
                let id = state.id().to_string();
                let is_selected = selected.as_deref() == Some(id.as_str());
                let enabled = state.config.enabled;
                let toggle_id = id.clone();
                let toggle_view = view.clone();
                let select_id = id.clone();
                h_flex()
                    .id(SharedString::from(format!("provider-{id}")))
                    .items_start()
                    .px(px(10.))
                    .py(px(10.))
                    .gap(px(10.))
                    .rounded(px(12.))
                    .cursor_pointer()
                    .map(|this| if is_selected { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) })
                    .on_click(cx.listener(move |this, _, window, cx| this.select_provider(&select_id, window, cx)))
                    .child(div().pt(px(2.)).child(pickers::provider_icon(state.kind(), px(16.), p)))
                    .child(
                        v_flex()
                            .flex_1()
                            .min_w_0()
                            .gap(px(2.))
                            .child(
                                h_flex()
                                    .gap(px(6.))
                                    .child(
                                        div()
                                            .truncate()
                                            .text_size(px(13.5))
                                            .font_weight(FontWeight::MEDIUM)
                                            .text_color(p.ink)
                                            .child(state.label()),
                                    )
                                    .when_some(state.version.clone(), |this, version| {
                                        this.child(
                                            div()
                                                .font_family(metal::MONO)
                                                .text_size(px(10.5))
                                                .text_color(p.ink3)
                                                .child(format!("v{version}")),
                                        )
                                    }),
                            )
                            .child(
                                div()
                                    .text_size(px(12.))
                                    .line_height(px(16.))
                                    .text_color(p.ink3)
                                    .child(status_line(state)),
                            ),
                    )
                    .child(metal::toggle(SharedString::from(format!("provider-enabled-{id}")), enabled, p, move |on, window, cx| {
                        let id = toggle_id.clone();
                        let _ = toggle_view.update(cx, |this, cx| this.set_provider_enabled(&id, on, window, cx));
                    }))
                    .into_any_element()
            }))
            .child(
                MetalButton::new("add-provider")
                    .ghost()
                    .compact()
                    .icon(Lucide::Plus)
                    .label("Add provider")
                    .dropdown_menu(add_provider_menu(view.clone())),
            );

        let detail = match (selected.as_deref().and_then(|id| catalog.instance(id)).cloned(), &self.provider_form) {
            (Some(state), Some(form)) if form.id == state.id() => self.render_provider_detail(&state, form, window, cx),
            (Some(state), _) => {
                // The form is built on first open; until then show its summary.
                let id = state.id().to_string();
                let view = view.clone();
                window.defer(cx, move |window, cx| {
                    let _ = view.update(cx, |this, cx| this.select_provider(&id, window, cx));
                });
                div().into_any_element()
            }
            (None, _) => div().p(px(18.)).text_size(px(13.)).text_color(p.ink3).child("No providers.").into_any_element(),
        };

        let providers = v_flex()
            .gap(px(10.))
            .child(header)
            .child(
                h_flex()
                    .items_start()
                    .rounded(px(18.))
                    .raised(p)
                    .child(list)
                    .child(div().w(px(1.)).self_stretch().bg(p.rule))
                    .child(div().flex_1().min_w_0().child(detail)),
            )
            .into_any_element();

        let health = settings::get(|settings| settings.health_check_secs);
        let advanced = section(
            "Advanced",
            None,
            vec![row(
                "Health check interval",
                "Refresh provider status, versions, models and limits in the background. Set to 0 to turn it off.",
                stepper("health-interval", health, 60, (0, 86_400), "seconds", &view, p, |settings, secs| {
                    settings.health_check_secs = secs
                }),
                p,
            )],
            p,
        );

        vec![providers, advanced]
    }

    fn render_provider_detail(
        &self,
        state: &InstanceState,
        form: &ProviderForm,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> AnyElement {
        let p = metal::palette(cx);
        let id = state.id().to_string();
        let kind = state.kind();
        let config = state.config.clone();
        let found = claude::find_binary(&config);
        let removable = id != kind.key();
        let updating = self.updating.contains(&id);

        let field_row = |title: &'static str, description: String, input: &Entity<InputState>, window: &mut Window, cx: &mut Context<Self>| {
            row(title, description, text_field(input, 250., window, cx), p)
        };
        let mut runtime = vec![
            field_row(
                "Binary path",
                binary_status(found.as_deref(), &config),
                &form.binary,
                window,
                cx,
            ),
            field_row(
                match kind {
                    Provider::Claude => "Config folder",
                    Provider::Pi => "Agent folder",
                },
                format!(
                    "Sets {} so this instance keeps its own login, settings and sessions.",
                    claude::home_var(kind)
                ),
                &form.home,
                window,
                cx,
            ),
            field_row(
                "Launch arguments",
                format!(
                    "Extra arguments passed to {} when a thread starts.",
                    match kind {
                        Provider::Claude => "claude",
                        Provider::Pi => "pi --mode rpc",
                    }
                ),
                &form.args,
                window,
                cx,
            ),
        ];
        if state.update_available() || state.latest.is_some() {
            let latest = state.latest.clone().unwrap_or_default();
            let available = state.update_available();
            let view = cx.weak_entity();
            let update_id = id.clone();
            runtime.push(row(
                "Version",
                if available {
                    format!("{} is out; this is {}.", latest, state.version.clone().unwrap_or_else(|| "unknown".into()))
                } else {
                    format!("Up to date ({latest}).")
                },
                MetalButton::new("update-provider")
                    .compact()
                    .icon(IconName::ArrowUp)
                    .label(if updating { "Updating…" } else { "Update" })
                    .disabled(!available || updating)
                    .on_click(move |_, window, cx| {
                        let _ = view.update(cx, |this, cx| this.update_provider(&update_id, window, cx));
                    }),
                p,
            ));
        }

        let env_rows: Vec<AnyElement> = form
            .env
            .iter()
            .enumerate()
            .map(|(ix, (key, value))| {
                h_flex()
                    .px(px(18.))
                    .py(px(8.))
                    .gap(px(8.))
                    .child(text_field(key, 170., window, cx))
                    .child(div().text_color(p.ink3).child("="))
                    .child(div().flex_1().min_w_0().child(text_field(value, 220., window, cx).w_full()))
                    .child(
                        MetalButton::new(("remove-env", ix))
                            .ghost()
                            .mini()
                            .icon(Lucide::Trash)
                            .tooltip("Remove")
                            .on_click(cx.listener(move |this, _, window, cx| this.remove_env_var(ix, window, cx))),
                    )
                    .into_any_element()
            })
            .collect();
        let mut environment = vec![row(
            "Variables",
            "API keys, base URLs and other settings for this instance's CLI.",
            MetalButton::new("add-env")
                .compact()
                .icon(Lucide::Plus)
                .label("Add variable")
                .on_click(cx.listener(|this, _, window, cx| this.add_env_var(window, cx))),
            p,
        )];
        environment.extend(env_rows);

        let remove_id = id.clone();
        v_flex()
            .p(px(14.))
            .gap(px(18.))
            .child(
                h_flex()
                    .px(px(4.))
                    .gap(px(10.))
                    .child(pickers::provider_icon(kind, px(18.), p))
                    .child(div().flex_1().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child(state.label()))
                    .child(metal::led(
                        "provider-led",
                        match &state.status {
                            ProbeStatus::Ready => Led::Live,
                            ProbeStatus::Checking => Led::Waiting,
                            ProbeStatus::Failed(_) | ProbeStatus::Missing => Led::Failed,
                            ProbeStatus::Disabled => Led::Off,
                        },
                        state.checking,
                        p,
                    ))
                    .when(removable, |this| {
                        this.child(
                            MetalButton::new("remove-provider")
                                .ghost()
                                .compact()
                                .icon(Lucide::Trash)
                                .label("Remove")
                                .on_click(cx.listener(move |this, _, window, cx| {
                                    this.remove_provider(&remove_id, window, cx)
                                })),
                        )
                    }),
            )
            .child(card(
                vec![row(
                    "Display name",
                    status_line(state),
                    text_field(&form.name, 250., window, cx),
                    p,
                )],
                p,
            ))
            .child(subsection("Runtime", card(runtime, p), p))
            .child(subsection("Environment", card(environment, p), p))
            .into_any_element()
    }

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

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

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

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

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

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

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

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

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

fn switch(
    id: &'static str,
    on: bool,
    view: &WeakEntity<SettingsView>,
    p: &Palette,
    set: impl Fn(&mut Settings, bool) + 'static,
) -> Stateful<Div> {
    switch_then(id, on, view, p, set, |_| {})
}

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

/// A dropdown of fixed choices.
fn choice<T: Copy + PartialEq + 'static>(
    id: &'static str,
    current: T,
    options: &'static [(T, &'static str)],
    view: &WeakEntity<SettingsView>,
    set: impl Fn(&mut Settings, T) + 'static,
) -> AnyElement {
    let label = options.iter().find(|(value, _)| *value == current).map_or("", |(_, label)| *label);
    let view = view.clone();
    let set = Rc::new(set);
    MetalButton::new(id)
        .compact()
        .label(label)
        .caret()
        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
            menu = menu.min_w(px(200.));
            for &(value, label) in options {
                let view = view.clone();
                let set = set.clone();
                menu = menu.item(PopupMenuItem::new(label).checked(value == current).on_click(move |_, window, cx| {
                    let set = set.clone();
                    edit(&view, window, cx, move |settings| set(settings, value));
                }));
            }
            menu
        })
        .into_any_element()
}

/// A number with − and + caps, like T3 Code's interval field.
#[allow(clippy::too_many_arguments)]
fn stepper(
    id: &'static str,
    value: u64,
    step: u64,
    (min, max): (u64, u64),
    unit: &'static str,
    view: &WeakEntity<SettingsView>,
    p: &Palette,
    set: impl Fn(&mut Settings, u64) + 'static,
) -> Div {
    let set = Rc::new(set);
    let button = |suffix: &'static str, icon: IconName, next: u64, enabled: bool| {
        let view = view.clone();
        let set = set.clone();
        MetalButton::new(SharedString::from(format!("{id}-{suffix}")))
            .ghost()
            .mini()
            .icon(icon)
            .disabled(!enabled)
            .on_click(move |_, window, cx| {
                let set = set.clone();
                edit(&view, window, cx, move |settings| set(settings, next));
            })
    };
    h_flex()
        .gap(px(8.))
        .child(
            h_flex()
                .h(px(32.))
                .px(px(3.))
                .gap(px(2.))
                .rounded(px(12.))
                .well(p)
                .child(button("less", IconName::Minus, value.saturating_sub(step).max(min), value > min))
                .child(
                    div()
                        .min_w(px(48.))
                        .flex()
                        .justify_center()
                        .font_family(metal::MONO)
                        .text_size(px(13.))
                        .text_color(p.ink)
                        .child(value.to_string()),
                )
                .child(button("more", IconName::Plus, (value + step).min(max), value < max)),
        )
        .child(div().text_size(px(12.5)).text_color(p.ink3).child(unit))
}

/// A model dropdown across every enabled provider, with a first entry for
/// "no model" (`none_label`: follow the open thread, or off).
fn model_control(
    id: &'static str,
    current: Option<String>,
    none_label: &'static str,
    pick: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
    cx: &mut Context<SettingsView>,
) -> AnyElement {
    let catalog = Catalog::global(cx);
    let p = metal::palette(cx);
    let (icon, label): (Option<Provider>, SharedString) = match &current {
        None => (None, none_label.into()),
        Some(key) => match catalog.find_key(key) {
            Some(model) => (Some(model.provider), model.name.clone().into()),
            None => {
                let (instance, model) = split_key(key).unwrap_or((key, key));
                let kind = catalog.instance(instance).map(|state| state.kind()).or_else(|| Provider::from_key(instance));
                (kind, model.to_string().into())
            }
        },
    };
    // One menu section per enabled instance: its label, then its models.
    let groups: Vec<(String, Vec<(String, String)>, String)> = catalog
        .enabled()
        .map(|state| {
            let models = state
                .models
                .iter()
                .map(|model| {
                    let name = match model.provider {
                        Provider::Claude if model.legacy => format!("{} (legacy)", model.name),
                        Provider::Claude => model.name.clone(),
                        Provider::Pi => format!("{} · {}", model.name, model.detail),
                    };
                    (model.key(), name)
                })
                .collect();
            let note = match &state.status {
                ProbeStatus::Checking => "Asking for its models…".to_string(),
                ProbeStatus::Missing => "Not installed".to_string(),
                ProbeStatus::Failed(_) => "Didn't list its models".to_string(),
                _ => "No models".to_string(),
            };
            (state.label(), models, note)
        })
        .collect();
    let pick = Rc::new(pick);
    MetalButton::new(id)
        .compact()
        .map(|this| match icon {
            Some(provider) => this.icon(pickers::provider_icon(provider, px(13.), p)),
            None => this,
        })
        .label(label)
        .caret()
        .max_w(px(260.))
        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
            let item = |key: Option<String>| {
                let pick = pick.clone();
                move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(key.clone(), window, cx)
            };
            menu = menu
                .min_w(px(260.))
                .max_h(px(440.))
                .scrollable(true)
                .item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None)));
            for (label, models, note) in &groups {
                menu = menu.separator().label(label.clone());
                if models.is_empty() {
                    menu = menu.item(PopupMenuItem::new(note.clone()).disabled(true));
                }
                for (key, name) in models {
                    let checked = current.as_deref() == Some(key.as_str());
                    menu = menu.item(PopupMenuItem::new(name.clone()).checked(checked).on_click(item(Some(key.clone()))));
                }
            }
            menu
        })
        .into_any_element()
}

/// A reasoning dropdown, with a first entry for "no level" (`none_label`).
fn reasoning_control(
    id: &'static str,
    current: Option<Reasoning>,
    none_label: &'static str,
    pick: impl Fn(Option<Reasoning>, &mut Window, &mut App) + 'static,
) -> AnyElement {
    let mut choices = vec![Reasoning::Default];
    choices.extend([Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max].map(Reasoning::Level));
    choices.extend([Reasoning::Ultracode, Reasoning::Ultrathink]);
    let pick = Rc::new(pick);
    MetalButton::new(id)
        .compact()
        .icon(Lucide::Brain)
        .label(current.map(Reasoning::label).unwrap_or(none_label))
        .caret()
        .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
            let item = |reasoning: Option<Reasoning>| {
                let pick = pick.clone();
                move |_: &ClickEvent, window: &mut Window, cx: &mut App| pick(reasoning, window, cx)
            };
            menu = menu
                .min_w(px(220.))
                .item(PopupMenuItem::new(none_label).checked(current.is_none()).on_click(item(None)))
                .separator();
            for reasoning in choices.clone() {
                if reasoning == Reasoning::Default && none_label == "Default" {
                    continue;
                }
                if reasoning == Reasoning::Ultracode {
                    menu = menu.separator().label("Claude only");
                }
                menu = menu.item(
                    PopupMenuItem::new(reasoning.label()).checked(current == Some(reasoning)).on_click(item(Some(reasoning))),
                );
            }
            menu
        })
        .into_any_element()
}

/// The menu "Add provider" opens: one entry per kind.
fn add_provider_menu(
    view: WeakEntity<SettingsView>,
) -> impl Fn(gpui_kit::component::menu::PopupMenu, &mut Window, &mut Context<gpui_kit::component::menu::PopupMenu>) -> gpui_kit::component::menu::PopupMenu
+ 'static {
    move |mut menu, _, _| {
        menu = menu.min_w(px(220.)).label("Another instance of");
        for kind in Provider::ALL {
            let view = view.clone();
            menu = menu.item(
                PopupMenuItem::new(match kind {
                    Provider::Claude => "Claude Code",
                    Provider::Pi => "Pi",
                })
                .on_click(move |_, window, cx| {
                    let _ = view.update(cx, |this, cx| this.add_provider(kind, window, cx));
                }),
            );
        }
        menu
    }
}

/// `Authenticated · Claude Team Subscription`, `Disabled`, `Not installed`.
fn status_line(state: &InstanceState) -> String {
    match &state.status {
        ProbeStatus::Disabled => "Disabled".into(),
        ProbeStatus::Checking => "Checking…".into(),
        ProbeStatus::Missing => "Not installed".into(),
        ProbeStatus::Failed(error) => format!("Unavailable · {error}"),
        ProbeStatus::Ready => {
            let mut parts = Vec::new();
            match state.kind() {
                Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()),
                Provider::Claude => parts.push("Not signed in".to_string()),
                Provider::Pi if state.models.is_empty() => parts.push("No models configured".to_string()),
                Provider::Pi => parts.push(format!("{} models", state.models.len())),
            }
            parts.extend(state.plan.clone());
            if state.update_available() {
                parts.push("Update available".into());
            }
            parts.join(" · ")
        }
    }
}

/// `2m ago`, `just now`.
fn ago(time: SystemTime) -> String {
    let secs = time.elapsed().unwrap_or_default().as_secs();
    match secs {
        0..60 => "just now".into(),
        60..3_600 => format!("{}m ago", secs / 60),
        3_600..86_400 => format!("{}h ago", secs / 3_600),
        _ => format!("{}d ago", secs / 86_400),
    }
}

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

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

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

/// A smaller heading over a card, inside a page section.
fn subsection(title: &'static str, card: Div, p: &Palette) -> Div {
    v_flex()
        .gap(px(8.))
        .child(div().px(px(4.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(title))
        .child(card)
}

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

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

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

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

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