Vectrum
Sign In
689 lines · 36.6 KB · Rust
//! The General page, row for row after T3 Code's: New threads,
//! Organization, Behavior, Projects & threads, Confirmations, Text
//! generation, About, Diagnostics and the folded Legacy features.

use gpui_kit::component::h_flex;
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::widgets::{
    RichOption, Section, SettingRow, choice, code, edit, folded, model_control, reasoning_control, reset,
    rich_choice, stepper, switch, switch_then, text_field,
};
use super::{Page, SettingsView};
use crate::claude::PermissionMode;
use crate::models::Catalog;
use crate::session::Reasoning;
use crate::settings::{
    BackgroundActivity, FollowUp, ProjectOrder, QuitShortcut, SendShortcut, Streaming, Submodules,
    TimeFormat, UsageBar, WorkspaceMode,
};
use crate::ui::metal::{self, MetalButton};
use crate::ui::{pickers, workspace};
use crate::update::{Stage, Updater};

const WORKSPACES: &[(WorkspaceMode, &str)] =
    &[(WorkspaceMode::Local, "Current checkout"), (WorkspaceMode::Worktree, "New worktree")];
const SUBMODULES: &[(Submodules, &str)] =
    &[(Submodules::Recursive, "Recursive"), (Submodules::TopLevel, "Top level only"), (Submodules::Skip, "Skip")];
const PROJECT_ORDERS: &[(ProjectOrder, &str)] = &[
    (ProjectOrder::LastMessage, "Last user message"),
    (ProjectOrder::Added, "Created at"),
    (ProjectOrder::Name, "Name"),
];
/// `(notify, sound)`.
const NOTIFICATIONS: &[((bool, bool), &str)] = &[
    ((false, false), "Off"),
    ((true, false), "Notifications only"),
    ((false, true), "Sound only"),
    ((true, true), "Notifications with sound"),
];
const TIME_FORMATS: &[(TimeFormat, &str)] =
    &[(TimeFormat::System, "System default"), (TimeFormat::Hour12, "12-hour"), (TimeFormat::Hour24, "24-hour")];
const STREAMING: &[(Streaming, &str)] = &[
    (Streaming::Turn, "Wait for the full response"),
    (Streaming::Paragraphs, "Show finished paragraphs"),
    (Streaming::Tokens, "Stream every token"),
];
const DIFF_STATES: &[(bool, &str)] = &[(false, "Expanded"), (true, "Collapsed")];
const DIFF_LAYOUTS: &[(bool, &str)] = &[(false, "Stacked"), (true, "Split")];
const SEND_SHORTCUTS: &[(SendShortcut, &str)] = &[
    (SendShortcut::Enter, "Enter"),
    (SendShortcut::ModEnterMultiline, "Ctrl + Enter for multiline prompts"),
    (SendShortcut::ModEnter, "Ctrl + Enter always"),
];
const FOLLOW_UPS: &[(FollowUp, &str)] = &[(FollowUp::Queue, "Queue"), (FollowUp::Steer, "Steer")];
const USAGE_BARS: &[(UsageBar, &str)] = &[(UsageBar::Claude, "Claude"), (UsageBar::Codex, "Codex"), (UsageBar::Off, "Off")];
const BACKGROUND: &[(BackgroundActivity, &str)] = &[
    (BackgroundActivity::Balanced, "Balanced"),
    (BackgroundActivity::Always, "Performance"),
    (BackgroundActivity::Minimal, "Battery saver"),
];
const QUIT_SHORTCUTS: &[(QuitShortcut, &str)] =
    &[(QuitShortcut::Press, "Direct"), (QuitShortcut::Hold, "Hold"), (QuitShortcut::Double, "Double press")];

/// T3 Code's description of each permission mode.
fn permission_description(mode: PermissionMode) -> &'static str {
    match mode {
        PermissionMode::Default => "Ask before commands and file changes.",
        PermissionMode::AcceptEdits => "Auto-approve edits, ask before other actions.",
        PermissionMode::Auto => "Supported providers approve routine actions; others still ask.",
        PermissionMode::Plan => "Plan first, without editing files or running commands.",
        PermissionMode::BypassPermissions => "Allow commands and edits without prompts.",
    }
}

impl SettingsView {
    pub(super) fn render_general(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let saved = super::scope::shown_settings(cx);
        let view = cx.weak_entity();
        let row = SettingRow::new;

        // ---- New threads ----
        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.clone())
            }
        };
        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 mut permissions = vec![RichOption {
            value: None,
            icon: None,
            label: "Automatic",
            description: "Start with the permissions of the thread you have open.",
        }];
        permissions.extend(PermissionMode::ALL.into_iter().map(|mode| RichOption {
            value: Some(mode),
            icon: Some(pickers::access_icon(mode)),
            label: mode.label(),
            description: permission_description(mode),
        }));
        let current_access = saved.default_access.as_deref().and_then(PermissionMode::from_cli);
        let new_threads = Section::new("New threads")
            .id("project-defaults")
            .row(
                row("Model")
                    .id("default-model")
                    .description("Default model for new threads. Projects can override it.")
                    .when(saved.default_model.is_none(), |row| row.status("Automatic"))
                    .reset(reset(&view, cx, |s| (s.default_model.clone(), s.default_reasoning.clone()), |s, (model, reasoning)| {
                        s.default_model = model;
                        s.default_reasoning = reasoning;
                    }))
                    .control(
                        h_flex()
                            .gap(px(6.))
                            .child(model_control("default-model", saved.default_model.clone(), "Automatic", false, model_pick, cx))
                            .child(reasoning_control(
                                "default-reasoning",
                                saved.default_reasoning.as_deref().and_then(Reasoning::from_key),
                                "Automatic",
                                reasoning_pick,
                            )),
                    ),
            )
            .row(
                row("Permissions")
                    .id("default-permissions")
                    .description("Default permissions for new threads. Pi, jcode and Empryo threads always run with full access.")
                    .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access))
                    .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| {
                        settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string())
                    })),
            )
            .row(
                row("Workspace")
                    .id("new-threads")
                    .description("Where new threads start. The start screen can switch it for each thread.")
                    .reset(reset(&view, cx, |s| s.workspace, |s, mode| s.workspace = mode))
                    .control(choice("workspace", saved.workspace, WORKSPACES, &view, |settings, mode| settings.workspace = mode)),
            )
            .row(
                row("Submodules")
                    .id("worktree-submodules")
                    .description("How new worktrees populate git submodules.")
                    .reset(reset(&view, cx, |s| s.submodules, |s, mode| s.submodules = mode))
                    .control(choice("submodules", saved.submodules, SUBMODULES, &view, |settings, mode| settings.submodules = mode)),
            )
            .render(cx);

        // ---- Organization ----
        let mut organization = Section::new("Organization")
            .row(
                row("Project grouping")
                    .id("project-grouping")
                    .description("Combine matching repositories across environments.")
                    .reset(reset(&view, cx, |s| s.project_grouping, |s, grouping| s.project_grouping = grouping))
                    // On groups by repository, as T3 Code's switch does; off keeps every project apart.
                    .control(switch(
                        "project-grouping",
                        saved.project_grouping != crate::settings::ProjectGrouping::Separate,
                        &view,
                        p,
                        |settings, on| {
                            settings.project_grouping = if on {
                                crate::settings::ProjectGrouping::Repository
                            } else {
                                crate::settings::ProjectGrouping::Separate
                            }
                        },
                    )),
            )
            .row(
                row("Project order")
                    .id("project-order")
                    .description("Order of projects in the sidebar project picker and command palette.")
                    .reset(reset(&view, cx, |s| s.project_order, |s, order| s.project_order = order))
                    .control(choice("project-order", saved.project_order, PROJECT_ORDERS, &view, |settings, order| {
                        settings.project_order = order
                    })),
            )
            .row(
                row("Auto-resume limited threads")
                    .id("auto-resume-limited-threads")
                    .description(
                        "Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation.",
                    )
                    .reset(reset(&view, cx, |s| s.auto_resume_limited, |s, on| s.auto_resume_limited = on))
                    .control(switch("auto-resume", saved.auto_resume_limited, &view, p, |settings, on| {
                        settings.auto_resume_limited = on
                    })),
            )
            .row(
                row("Snooze limited threads")
                    .id("snooze-limited-threads")
                    .description(
                        "Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake.",
                    )
                    .reset(reset(&view, cx, |s| s.snooze_limited, |s, on| s.snooze_limited = on))
                    .control(switch("snooze-limited", saved.snooze_limited, &view, p, |settings, on| settings.snooze_limited = on)),
            )
            .row(
                row("Working section (beta)")
                    .id("working-shelf")
                    .description(
                        "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you.",
                    )
                    .reset(reset(&view, cx, |s| s.working_section, |s, on| s.working_section = on))
                    .control(switch("working-section", saved.working_section, &view, p, |settings, on| {
                        settings.working_section = on
                    })),
            )
            .row(
                row("Auto-settle merged threads")
                    .id("auto-settle-merged-threads")
                    .description("Settle a thread when its pull request merges. Closed pull requests still settle automatically.")
                    .reset(reset(&view, cx, |s| s.auto_settle_merged, |s, on| s.auto_settle_merged = on))
                    .control(switch("auto-settle-merged", saved.auto_settle_merged, &view, p, |settings, on| {
                        settings.auto_settle_merged = on
                    })),
            )
            .row(
                row("Auto-settle inactive threads")
                    .id("auto-settle-inactive-threads")
                    .description("Sidebar threads with no activity for this long settle automatically.")
                    .reset(reset(&view, cx, |s| (s.auto_settle_inactive, s.inactive_days), |s, (on, days)| {
                        s.auto_settle_inactive = on;
                        s.inactive_days = days;
                    }))
                    .control(switch("auto-settle-inactive", saved.auto_settle_inactive, &view, p, |settings, on| {
                        settings.auto_settle_inactive = on;
                        if on {
                            settings.inactive_days = 3;
                        }
                    })),
            );
        if saved.auto_settle_inactive {
            organization = organization.row(
                row("Days of inactivity before auto-settle")
                    .id("days-before-auto-settle")
                    .description("Any new activity un-settles a thread automatically.")
                    .control(stepper("inactive-days", u64::from(saved.inactive_days), 1, (1, 90), "", &view, p, |settings, days| {
                        settings.inactive_days = days as u32
                    })),
            );
        }
        let organization = organization.render(cx);

        // ---- Behavior ----
        let can_notify = workspace::find_program(&["notify-send"]).is_some();
        let notification_mode = (saved.notify, saved.notify_sound);
        let streaming_description = match saved.streaming {
            Streaming::Turn => "Text appears once the agent finishes its turn.",
            Streaming::Paragraphs => "Each paragraph or code block appears as soon as it is complete.",
            Streaming::Tokens => "Text appears token by token as the agent writes it.",
        };
        let follow_up_description = format!(
            "Queue follow-ups while the agent runs or steer the current run. {}",
            match saved.send_shortcut {
                SendShortcut::Enter => "Press Ctrl + Enter to do the opposite for one message.",
                SendShortcut::ModEnter => "Press Ctrl + Shift + Enter to do the opposite for one message.",
                SendShortcut::ModEnterMultiline => {
                    "Press Ctrl + Enter for single-line prompts or Ctrl + Shift + Enter for multiline prompts to do the opposite for one message."
                }
            }
        );
        let background_description = match saved.background_activity {
            BackgroundActivity::Balanced => {
                "Pauses health checks and git refreshes while the window has been idle for a while, or in low power mode."
            }
            BackgroundActivity::Always => "Allows background checks whenever the app is open.",
            BackgroundActivity::Minimal => "Also pauses background checks whenever the window is in the background.",
        };
        let behavior = Section::new("Behavior")
            .row(
                row("Thread notifications")
                    .id("thread-notifications")
                    .description(if can_notify || notification_mode.0 == false {
                        "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while Vectrum Code is open."
                    } else {
                        "Notifications need notify-send (libnotify), which isn't installed. Sound only is still available."
                    })
                    .reset(reset(&view, cx, |s| (s.notify, s.notify_sound), |s, (notify, sound)| {
                        s.notify = notify;
                        s.notify_sound = sound;
                    }))
                    .control(choice("thread-notifications", notification_mode, NOTIFICATIONS, &view, |settings, (notify, sound)| {
                        settings.notify = notify;
                        settings.notify_sound = sound;
                    })),
            )
            .row(
                row("In-app notifications")
                    .id("in-app-notifications")
                    .description(
                        "Show a toast when another thread finishes, fails, or needs input or approval while this app has focus.",
                    )
                    .control(switch("in-app-notifications", saved.in_app_notifications, &view, p, |settings, on| {
                        settings.in_app_notifications = on
                    })),
            )
            .row(
                row("Time format")
                    .id("time-format")
                    .description("System default follows your OS clock preference.")
                    .reset(reset(&view, cx, |s| s.time_format, |s, format| s.time_format = format))
                    .control(choice("time-format", saved.time_format, TIME_FORMATS, &view, |settings, format| {
                        settings.time_format = format
                    })),
            )
            .row(
                row("Response streaming")
                    .id("response-streaming")
                    .description(streaming_description)
                    .reset(reset(&view, cx, |s| s.streaming, |s, streaming| s.streaming = streaming))
                    .control(choice("streaming", saved.streaming, STREAMING, &view, |settings, streaming| {
                        settings.streaming = streaming
                    })),
            )
            .row(
                row("Fold finished turns")
                    .id("fold-turns")
                    .description("Tuck a finished turn's tool calls and interim messages into one \u{201c}Worked for\u{201d} row.")
                    .reset(reset(&view, cx, |s| s.fold_turns, |s, on| s.fold_turns = on))
                    .control(switch("fold-turns", saved.fold_turns, &view, p, |settings, on| settings.fold_turns = on)),
            )
            .row(
                row("Show costs")
                    .id("show-costs")
                    .description("What each turn and thread cost, in the transcript and the project panel.")
                    .reset(reset(&view, cx, |s| s.show_costs, |s, on| s.show_costs = on))
                    .control(switch("show-costs", saved.show_costs, &view, p, |settings, on| settings.show_costs = on)),
            )
            .row(
                row("Hide whitespace changes")
                    .id("hide-whitespace-changes")
                    .description("Set whether the diff panel ignores whitespace-only edits by default.")
                    .reset(reset(&view, cx, |s| s.hide_whitespace, |s, on| s.hide_whitespace = on))
                    .control(switch("hide-whitespace", saved.hide_whitespace, &view, p, |settings, on| {
                        settings.hide_whitespace = on
                    })),
            )
            .row(
                row("Default diff file state")
                    .id("default-diff-file-state")
                    .description("Start with files expanded or collapsed when opening diffs.")
                    .reset(reset(&view, cx, |s| s.diff_collapsed, |s, collapsed| s.diff_collapsed = collapsed))
                    .control(choice("diff-collapsed", saved.diff_collapsed, DIFF_STATES, &view, |settings, collapsed| {
                        settings.diff_collapsed = collapsed
                    })),
            )
            .row(
                row("Diff layout")
                    .id("diff-layout")
                    .description("Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.")
                    .reset(reset(&view, cx, |s| s.diff_side_by_side, |s, split| s.diff_side_by_side = split))
                    .control(choice("diff-layout", saved.diff_side_by_side, DIFF_LAYOUTS, &view, |settings, split| {
                        settings.diff_side_by_side = split
                    })),
            )
            .row(
                row("Proactive panels")
                    .id("proactive-panels")
                    .description("Open Changes for edits to at least 3 files or 50 lines.")
                    .reset(reset(&view, cx, |s| s.proactive_panels, |s, on| s.proactive_panels = on))
                    .control(switch("proactive-panels", saved.proactive_panels, &view, p, |settings, on| {
                        settings.proactive_panels = on
                    })),
            )
            .row(
                row("Show skills in slash menu")
                    .id("skills-in-slash-menu")
                    .description("Also include skills in the / command menu. Skills always appear when you type $.")
                    .reset(reset(&view, cx, |s| s.slash_skills, |s, on| s.slash_skills = on))
                    .control(switch("slash-skills", saved.slash_skills, &view, p, |settings, on| settings.slash_skills = on)),
            )
            .row(
                row("Rich text composer")
                    .id("composer-rich-text")
                    .description("Show formatted Markdown as you type.")
                    .reset(reset(&view, cx, |s| s.rich_composer, |s, on| s.rich_composer = on))
                    .control(switch("rich-composer", saved.rich_composer, &view, p, |settings, on| settings.rich_composer = on)),
            )
            .row(
                row("Inline diagrams and UI")
                    .id("inline-ui")
                    .description(
                        "Let agents draw diagrams, charts and small interactive UIs in their answers. Applies to the agent instructions when a thread's agent next starts.",
                    )
                    .reset(reset(&view, cx, |s| s.inline_ui, |s, on| s.inline_ui = on))
                    .control(switch("inline-ui", saved.inline_ui, &view, p, |settings, on| settings.inline_ui = on)),
            )
            .row(
                row("Collapse composer on scroll")
                    .id("composer-collapse")
                    .description(
                        "Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again.",
                    )
                    .reset(reset(&view, cx, |s| s.collapse_composer, |s, on| s.collapse_composer = on))
                    .control(switch("collapse-composer", saved.collapse_composer, &view, p, |settings, on| {
                        settings.collapse_composer = on
                    })),
            )
            .row(
                row("Send shortcut")
                    .id("send-shortcut")
                    .description("Choose when Enter sends a prompt or inserts a new line")
                    .reset(reset(&view, cx, |s| s.send_shortcut, |s, shortcut| s.send_shortcut = shortcut))
                    .control(choice("send-shortcut", saved.send_shortcut, SEND_SHORTCUTS, &view, |settings, shortcut| {
                        settings.send_shortcut = shortcut
                    })),
            )
            .row(
                row("Follow-up behavior")
                    .id("follow-up-behavior")
                    .description(follow_up_description)
                    .reset(reset(&view, cx, |s| s.follow_up, |s, follow_up| s.follow_up = follow_up))
                    .control(choice("follow-up", saved.follow_up, FOLLOW_UPS, &view, |settings, follow_up| {
                        settings.follow_up = follow_up
                    })),
            )
            .row(
                row("Usage bar")
                    .id("usage-bar")
                    .description("Show plan limits, context and output speed under the composer, with limits from Claude or Codex.")
                    .reset(reset(&view, cx, |s| s.usage_bar, |s, bar| s.usage_bar = bar))
                    .control(choice("usage-bar", saved.usage_bar, USAGE_BARS, &view, |settings, bar| settings.usage_bar = bar)),
            )
            .row(
                row("Provider update checks")
                    .id("provider-update-checks")
                    .description("Check installed provider CLIs for newer available versions.")
                    .reset(reset(&view, cx, |s| s.update_checks, |s, on| s.update_checks = on))
                    .control(switch_then(
                        "update-checks",
                        saved.update_checks,
                        &view,
                        p,
                        |settings, on| settings.update_checks = on,
                        Catalog::refresh_all,
                    )),
            )
            .row(
                row("Continue threads after restarts")
                    .id("continue-threads-after-server-update")
                    .description("Automatically resume interrupted threads after an update, crash, or machine restart.")
                    .reset(reset(&view, cx, |s| s.continue_after_restart, |s, on| s.continue_after_restart = on))
                    .control(switch("continue-after-restart", saved.continue_after_restart, &view, p, |settings, on| {
                        settings.continue_after_restart = on
                    })),
            )
            .row(
                row("Background activity")
                    .id("background-activity")
                    .description(background_description)
                    .reset(reset(&view, cx, |s| s.background_activity, |s, activity| s.background_activity = activity))
                    .control(choice("background-activity", saved.background_activity, BACKGROUND, &view, |settings, activity| {
                        settings.background_activity = activity
                    })),
            )
            .row(
                row("Stop idle agents")
                    .id("stop-idle-agents")
                    .description(
                        "Stops a thread's agent process after this many minutes without activity, to free its memory. \
                         The next message resumes the conversation. Set to 0 to keep agents running.",
                    )
                    .reset(reset(&view, cx, |s| s.idle_agent_minutes, |s, minutes| s.idle_agent_minutes = minutes))
                    .control(stepper("idle-agent-minutes", saved.idle_agent_minutes, 5, (0, 1440), "min", &view, p, |settings, minutes| {
                        settings.idle_agent_minutes = minutes
                    })),
            )
            .render(cx);

        // ---- Projects & threads ----
        let projects = Section::new("Projects & threads")
            .row(
                row("Start from origin")
                    .id("start-from-origin")
                    .description("Creates the worktree from the latest matching branch on origin instead of your local branch.")
                    .reset(reset(&view, cx, |s| s.start_from_origin, |s, on| s.start_from_origin = on))
                    .control(switch("start-from-origin", saved.start_from_origin, &view, p, |settings, on| {
                        settings.start_from_origin = on
                    })),
            )
            .row(
                row("Add project starts in")
                    .id("add-project-starts-in")
                    .description("Leave empty to use \u{201c}~/\u{201d} when the Add Project browser opens.")
                    .reset(reset(&view, cx, |s| s.add_project_dir.clone(), |s, dir| s.add_project_dir = dir))
                    .control(text_field(&self.add_project_dir, 288., window, cx)),
            )
            .render(cx);

        // ---- Confirmations ----
        let confirmations = Section::new("Confirmations")
            .row(
                row("Unpin confirmation")
                    .id("unpin-confirmation")
                    .description("Ask before unpinning a thread from the pinned section.")
                    .reset(reset(&view, cx, |s| s.confirm_unpin, |s, on| s.confirm_unpin = on))
                    .control(switch("confirm-unpin", saved.confirm_unpin, &view, p, |settings, on| settings.confirm_unpin = on)),
            )
            .row(
                row("Archive confirmation")
                    .id("archive-confirmation")
                    .description("Require a second click on the inline archive action before a thread is archived.")
                    .reset(reset(&view, cx, |s| s.confirm_settle, |s, on| s.confirm_settle = on))
                    .control(switch("confirm-settle", saved.confirm_settle, &view, p, |settings, on| settings.confirm_settle = on)),
            )
            .row(
                row("Delete confirmation")
                    .id("delete-confirmation")
                    .description("Ask before deleting a thread and its chat history.")
                    .reset(reset(&view, cx, |s| s.confirm_delete, |s, on| s.confirm_delete = on))
                    .control(switch("confirm-delete", saved.confirm_delete, &view, p, |settings, on| settings.confirm_delete = on)),
            )
            .row(
                row("Quit shortcut")
                    .id("quit-confirmation")
                    .description("Hold mode also quits on two quick presses.")
                    .reset(reset(&view, cx, |s| s.quit_shortcut, |s, shortcut| s.quit_shortcut = shortcut))
                    .control(choice("quit-shortcut", saved.quit_shortcut, QUIT_SHORTCUTS, &view, |settings, shortcut| {
                        settings.quit_shortcut = shortcut
                    })),
            )
            .row(
                row("Close confirmation")
                    .id("close-confirmation")
                    .description("Ask before Ctrl+Q, Alt+F4 or the window's close button closes the app.")
                    .reset(reset(&view, cx, |s| s.confirm_close, |s, on| s.confirm_close = on))
                    .control(switch("confirm-close", saved.confirm_close, &view, p, |settings, on| settings.confirm_close = on)),
            )
            .render(cx);

        // ---- Text generation ----
        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.clone().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::new("Text generation")
            .row(
                row("Text generation model")
                    .id("text-generation-model")
                    .description("Used for thread titles and worktree branch names. Off keeps the first line of the first message.")
                    .reset(reset(&view, cx, |s| (s.text_model.clone(), s.text_reasoning.clone()), |s, (model, reasoning)| {
                        s.text_model = model;
                        s.text_reasoning = reasoning;
                    }))
                    .control(
                        h_flex()
                            .gap(px(6.))
                            .child(model_control("text-model", text_model.clone(), "Off", true, 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,
                                ))
                            }),
                    ),
            )
            .render(cx);

        // ---- About ----
        let (update_status, update_action) = match &Updater::global(cx).stage {
            Stage::Idle => ("Current version of the application.".to_string(), None),
            Stage::Checking => ("Checking for updates…".to_string(), None),
            Stage::Available(release) => (format!("Version {} is available.", release.version), Some("Download")),
            Stage::Downloading { release, done, total } => {
                let percent = if *total == 0 { 0 } else { done * 100 / total };
                (format!("Downloading version {}… {percent}%", release.version), None)
            }
            Stage::Ready(release) => (format!("Version {} is downloaded and installs on restart.", release.version), Some("Restart")),
            Stage::Failed { release, error } => (
                format!("Updating failed: {error}"),
                Some(if release.is_some() { "Try again" } else { "Check again" }),
            ),
        };
        let about = Section::new("About")
            .row(
                row("Version")
                    .id("version")
                    .title_suffix(code(env!("CARGO_PKG_VERSION"), p))
                    .description(update_status)
                    .control(match update_action {
                        Some(label) => MetalButton::new("app-update-action").compact().label(label).on_click(|_, _, cx| {
                            match &Updater::global(cx).stage {
                                Stage::Ready(_) => Updater::restart(cx),
                                Stage::Failed { release: None, .. } => Updater::check(true, cx),
                                _ => Updater::download(cx),
                            }
                        }),
                        None => MetalButton::new("check-for-updates")
                            .compact()
                            .label("Check for updates")
                            .disabled(matches!(Updater::global(cx).stage, Stage::Checking | Stage::Downloading { .. }))
                            .on_click(|_, _, cx| Updater::check(true, cx)),
                    }),
            )
            .row(
                row("Automatic update checks")
                    .id("automatic-update-checks")
                    .description("Look for a new version on vectrum.cc every few hours. Updates download only when you say so.")
                    .reset(reset(&view, cx, |s| s.app_update_checks, |s, on| s.app_update_checks = on))
                    .control(switch("app-update-checks", saved.app_update_checks, &view, p, |settings, on| {
                        settings.app_update_checks = on
                    })),
            )
            .render(cx);

        // ---- Diagnostics ----
        let diagnostics = Section::new("Diagnostics")
            .row(
                row("Diagnostics")
                    .id("diagnostics")
                    .description("Inspect processes, resource use, and logs on this environment.")
                    .control(
                        MetalButton::new("view-diagnostics")
                            .compact()
                            .label("View diagnostics")
                            .on_click(cx.listener(|this, _, _, cx| this.open_page(Page::Diagnostics, cx))),
                    ),
            )
            .row(
                row("Open source licenses")
                    .id("open-source-licenses")
                    .description("Notices for dependencies, assets, and optional tools used by Vectrum Code.")
                    .control(
                        MetalButton::new("view-licenses")
                            .compact()
                            .label("View licenses")
                            .on_click(cx.listener(|this, _, _, cx| this.open_page(Page::Licenses, cx))),
                    ),
            )
            .render(cx);

        // ---- Legacy features ----
        let legacy = folded(
            "legacy",
            "Legacy features",
            self.open_folds.contains("legacy"),
            vec![
                row("Plan mode (legacy)")
                    .id("legacy-plan-mode")
                    .description("Restore Build/Plan, /plan, /default, and Shift+Tab. Off uses build mode.")
                    .reset(reset(&view, cx, |s| s.plan_mode, |s, on| s.plan_mode = on))
                    .control(switch("plan-mode", saved.plan_mode, &view, p, |settings, on| settings.plan_mode = on))
                    .into_any_element(),
                row("Context window indicator (legacy)")
                    .id("legacy-context-window-indicator")
                    .description("Shows context window usage as a circular indicator in the composer.")
                    .reset(reset(&view, cx, |s| s.context_meter, |s, on| s.context_meter = on))
                    .control(switch("context-meter", saved.context_meter, &view, p, |settings, on| settings.context_meter = on))
                    .into_any_element(),
                row("Sidebar (legacy)")
                    .id("legacy-sidebar")
                    .description("Restore per-project thread trees instead of the default flat sidebar.")
                    .reset(reset(&view, cx, |s| s.legacy_sidebar, |s, on| s.legacy_sidebar = on))
                    .control(switch("legacy-sidebar", saved.legacy_sidebar, &view, p, |settings, on| settings.legacy_sidebar = on))
                    .into_any_element(),
            ],
            &view,
            p,
        );

        vec![new_threads, organization, behavior, projects, confirmations, text_generation, about, diagnostics, legacy]
    }
}