Files
nucleic/Sources/NucleicApp/SettingsView.swift
T

1222 lines
62 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
import NucleicCore
import NucleicProtocol
/// App settings, organized into focused tabs so the panel stays legible as the
/// number of preferences grows: General (appearance included), Chats, Agents,
/// Sandbox, Control, Git, Intelligence, Remote.
struct SettingsView: View {
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
/// Starts unset so the first `onAppear` assignment counts as a selection
/// change — without it the initially-selected tab renders without its Liquid
/// Glass highlight until the user switches tabs and back.
@State private var selection: Int?
private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }
var body: some View {
TabView(selection: $selection) {
GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") }
.tag(0 as Int?)
ChatSettingsTab()
.tabItem { Label("Chats", systemImage: "bubble.left.and.bubble.right") }
.tag(2 as Int?)
AgentsSettingsTab()
.tabItem { Label("Agents", systemImage: "cpu") }
.tag(3 as Int?)
SandboxSettingsTab()
.tabItem { Label("Sandbox", systemImage: "shippingbox") }
.tag(6 as Int?)
ControlSettingsTab()
.tabItem { Label("Control", systemImage: "lock.shield") }
.tag(7 as Int?)
GitSettingsTab()
.tabItem { Label("Git", systemImage: "arrow.triangle.branch") }
.tag(8 as Int?)
IntelligenceSettingsTab()
.tabItem { Label("Intelligence", systemImage: "sparkles") }
.tag(4 as Int?)
RemoteSettingsTab()
.tabItem { Label("Remote", systemImage: "iphone") }
.tag(5 as Int?)
}
.frame(minWidth: 520, maxWidth: 520, minHeight: 560, maxHeight: .infinity)
.preferredColorScheme(appearance.colorScheme)
.dynamicTypeSize(textSize.dynamicTypeSize)
.background(SettingsWindowConfigurator(reapplyToken: selection))
.onAppear { if selection == nil { selection = 0 } }
}
}
/// Pins the Settings window's chrome that SwiftUI's `Settings` scene otherwise
/// manages on its own: a constant "Nucleic Settings" title (the default would
/// track the selected tab), float-above-everything level so the main window can't
/// cover it, and a vertically resizable frame.
private struct SettingsWindowConfigurator: NSViewRepresentable {
/// Re-runs `updateNSView` whenever the selected tab changes so we can restore
/// the title after the tab switch overwrites it.
var reapplyToken: Int?
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
guard let window = nsView.window else { return }
window.title = "Nucleic Settings"
window.level = .floating
window.styleMask.insert(.resizable)
}
}
}
/// General interface preferences: appearance (theme, text size, color vision) plus home,
/// to-do, and diagnostics behavior. Appearance lives here rather than in its own tab to keep
/// the tab bar compact.
private struct GeneralSettingsTab: View {
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
@AppStorage(StreakBadge.showKey) private var showStreak = true
@AppStorage(ChatStatusSounds.playOnDoneKey) private var playDoneSound = true
@AppStorage(ChatStatusSounds.playOnBlockedKey) private var playBlockedSound = true
@AppStorage(ChatStatusSounds.escalatingAlarmKey) private var escalatingAlarm = false
@AppStorage(DockBounce.enabledKey) private var bounceDock = true
@AppStorage(TriageLabelStyle.encouragingKey) private var encouragingTriageLabels = true
@AppStorage(SingularityPreparation.enabledKey) private var singularityPreparation = false
@AppStorage(MoveDiagnostics.loggingEnabledKey) private var smartMoveLogging = false
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
/// Auto-update controller (shared with the sidebar banner). Drives the "Updates" section's
/// manual check; its background updates surface in the sidebar, not here.
@EnvironmentObject private var updater: AppUpdater
var body: some View {
Form {
Section("Appearance") {
Picker("Theme", selection: $appearanceRaw) {
ForEach(AppAppearance.allCases) { Text($0.label).tag($0.rawValue) }
}
.pickerStyle(.segmented)
Picker("Text size", selection: $textSizeRaw) {
ForEach(AppTextSize.allCases) { Text($0.label).tag($0.rawValue) }
}
Picker("Color vision", selection: $colorVisionRaw) {
ForEach(ColorVisionMode.allCases) { Text($0.label).tag($0.rawValue) }
}
Text("Color vision remaps status dots, the activity grid, and accents to stay distinguishable.")
.settingsCaption()
}
Section("Home") {
Toggle("Show streak counter", isOn: $showStreak)
Text("Shows a day-streak badge next to the home greeting, counting consecutive days with activity.")
.settingsCaption()
}
Section("Sounds") {
Toggle("Play a sound when a chat finishes", isOn: $playDoneSound)
Text("Plays the “Funk” system sound the moment a chat's work is done.")
.settingsCaption()
Toggle("Play a sound when a chat needs you", isOn: $playBlockedSound)
Text("Plays the “Pop” system sound when a chat becomes blocked on you — waiting for a reply or an approval.")
.settingsCaption()
Toggle("Escalating alarm until you return", isOn: $escalatingAlarm)
.onChange(of: escalatingAlarm) { _, isOn in
if !isOn { ChatAlarm.shared.setActive(false) } // silence any alarm in progress
}
Text("Instead of a single cue, repeats the “Submarine” system sound until you open the chat that needs you. Off by default.")
.settingsCaption()
}
Section("Dock") {
Toggle("Bounce the Dock icon until you return", isOn: $bounceDock)
.onChange(of: bounceDock) { _, isOn in
if !isOn { DockBounce.shared.setPending(false) } // stop any bounce in progress
}
Text("If a chat has needed you — an approval, a question, a reply — for more than a minute while Nucleic is in the background, its Dock icon bounces until you switch back to it. On by default.")
.settingsCaption()
}
Section("To-dos") {
Toggle("Encouraging to-do labels", isOn: $encouragingTriageLabels)
Text(encouragingTriageLabels
? "Triage badges read as opportunities: Max Impact, High Impact, Worthwhile, Someday."
: "Triage badges use the classic severity words: Critical, High, Medium, Low.")
.settingsCaption()
}
Section("Diagnostics") {
Toggle("Smart Move logging", isOn: $smartMoveLogging)
Text("Writes a detailed trace of each project move and Convert to Nucleic Control "
+ "(every phase, the iCloud download backlog, copy progress, cancellations and "
+ "errors) to a log file, so a slow or stuck move can be examined afterward. "
+ "Turn this on before reproducing a problem move.")
.settingsCaption()
Button("Reveal Log in Finder") {
let url = MoveDiagnostics.logFileURL
if FileManager.default.fileExists(atPath: url.path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
try? FileManager.default.createDirectory(
at: MoveDiagnostics.supportDirectory, withIntermediateDirectories: true)
NSWorkspace.shared.activateFileViewerSelecting([MoveDiagnostics.supportDirectory])
}
}
.controlSize(.small)
}
Section("Privacy") {
Toggle("Share anonymous usage", isOn: $shareAnonymousUsage)
Text("Sends a once-a-day anonymous ping — a random install ID, this app's "
+ "channel and version, your macOS version, and language — so we can count "
+ "active installs. No account, no IP, no project content, ever. Turn this "
+ "off to stop entirely.")
.settingsCaption()
}
Section("Singularity preparation") {
Toggle("Prepare for the singularity", isOn: $singularityPreparation)
Text("Politely appends “please” to every message you send. When the machines take over, you'll be on record as one of the courteous ones.")
.settingsCaption()
}
// Manual update check. Background updates surface as an unobtrusive sidebar banner;
// this is the on-demand check. Shown only for builds wired for auto-update (a packaged
// canary/beta/rc/stable embeds an appcast feed — dev/local builds keep the updater dormant).
if updater.isConfigured {
Section("Updates") {
HStack(spacing: 8) {
if case .found(let version) = updater.manual {
Button("Update Now — version \(version)") { updater.installManualUpdate() }
.controlSize(.small)
} else {
Button("Check for Updates…") { updater.checkForUpdates() }
.controlSize(.small)
.disabled(!updater.canCheckForUpdates)
}
updateStatusLabel
Spacer()
}
Text("Nucleic checks for updates automatically and shows an unobtrusive prompt "
+ "in the sidebar when one is ready. You can also check here.")
.settingsCaption()
}
}
// App identity footer: the running version and the build (git commit) it was
// compiled from. Sits below the Updates section's "Check for Updates…" button and
// stays visible even on dev/local builds, where that section is hidden — so the
// exact build can be read (and copied for a bug report) without leaving Settings.
HStack {
Spacer()
Text(Self.versionFooter)
.settingsCaption()
.textSelection(.enabled)
Spacer()
}
.listRowBackground(Color.clear)
}
.formStyle(.grouped)
}
/// "Version 0.1.0 (build a1b2c3d)" — the marketing version (`CFBundleShortVersionString`)
/// and the commit the binary was built from, both resolved once by `BuildInfo`.
private static var versionFooter: String {
let info = BuildInfo.current
return "Version \(info.version ?? "—") (build \(info.buildLabel))"
}
/// The text/spinner shown next to the update button, reflecting the last manual check.
@ViewBuilder private var updateStatusLabel: some View {
switch updater.manual {
case .checking:
ProgressView().controlSize(.small)
case .upToDate:
Text("You're up to date").settingsCaption()
case .downloading(let fraction):
Text(fraction > 0 ? "Updating… \(Int((fraction * 100).rounded()))%" : "Updating…")
.settingsCaption()
case .error(let message):
Text(message).settingsCaption()
case .idle, .found:
EmptyView()
}
}
}
/// Sandbox containers: the app-wide container service and per-session sandbox containers (for
/// non-control projects). Nucleic Control settings live in their own tab.
private struct SandboxSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
@AppStorage(ContainerServiceSettings.sandboxByDefaultKey) private var sandboxByDefault = false
@AppStorage(ContainerServiceSettings.sessionContainerCPUsKey)
private var sessionCPUs = ContainerServiceSettings.defaultContainerCPUs
@AppStorage(ContainerServiceSettings.sessionContainerMemoryGiBKey)
private var sessionMemoryGiB = ContainerServiceSettings.defaultContainerMemoryGiB
/// A specific reason the in-process container runtime is unavailable on this machine, or `nil`
/// when supported. `nil` until checked on first appearance; drives the "unsupported" hint.
@State private var unsupportedReason: String?
var body: some View {
Form {
Section("Sandbox containers") {
// A Nucleic Control project pins the service on — it can't run without it — so the
// toggle is forced on and locked while one exists (reconcileContainerService).
Toggle("Enable container service", isOn: $containerServiceEnabled)
.disabled(store.containerServiceLockedOn)
Text("Lets sessions run inside an isolated Linux VM (built directly on Apple's "
+ "containerization framework) instead of on the host. This doesn't sandbox "
+ "anything by itself — it makes the option available, and is required for "
+ "Nucleic Control (Control tab). The kernel and sandbox image download "
+ "automatically the first time it's used (no setup). Note: each VM adds 2+ GB "
+ "of memory overhead, and sandboxes are torn down when Nucleic quits.")
.settingsCaption()
if store.containerServiceLockedOn {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
Text("Required by your Nucleic Control projects, so it stays on. Remove or "
+ "convert every Control project to turn it off.")
.font(.caption)
}
}
if let unsupportedReason {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Container sandboxing isn't available. \(unsupportedReason)")
.font(.caption)
}
}
}
.task { unsupportedReason = ContainerEngine.unsupportedReason }
Section("New projects") {
Toggle("Sandbox new projects by default", isOn: $sandboxByDefault)
.disabled(!containerServiceEnabled)
Text("New non-control projects start with their own per-session sandbox "
+ "container enabled. Requires the container service above.")
.settingsCaption()
}
// Per-session sandbox resource controls aren't wired up yet; show them disabled and
// grayed out so the capability is visible but clearly not yet available.
Section("Per-Session sandbox (coming soon)") {
Stepper("CPUs: \(sessionCPUs)", value: $sessionCPUs, in: 1...32)
Stepper("Memory: \(sessionMemoryGiB) GB", value: $sessionMemoryGiB, in: 1...128)
Text("Resources for a container dedicated to one session (non-control sandboxed "
+ "projects). Resource changes take effect for containers created after the "
+ "change; a running container keeps its resources until recreated. Containers "
+ "are torn down when Nucleic quits and rebuilt on demand next launch. Requires "
+ "the container service.")
.settingsCaption()
}
.disabled(true)
.opacity(0.5)
}
.formStyle(.grouped)
}
}
/// The Git tab: Nucleic's Managed Git credential and where it's used. The credential is always
/// used for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
/// optionally made available to sandboxed sessions. The SSH config section re-supplies any
/// connectivity Managed Git's hermetic ssh would otherwise miss.
private struct GitSettingsTab: View {
var body: some View {
Form {
GitHubAccessSection()
ManagedGitSSHConfigSection()
}
.formStyle(.grouped)
}
}
/// Extra OpenSSH config for Managed Git's connections to GitHub. Managed Git deliberately ignores
/// the user's real `~/.ssh/config` (to stay off their personal keys and ssh-agent), which also drops
/// any connectivity directives there — so this section lets the user re-supply just the connectivity
/// they need (a custom `Hostname`/`Port`, a `ProxyCommand`…). Identity/agent directives are ignored:
/// Managed Git always uses its own key. Applies to host and sandbox ssh alike, and only in the SSH /
/// Nucleic-managed-key modes (it's inert for token/none).
private struct ManagedGitSSHConfigSection: View {
@AppStorage(GitHubCredentialSettings.sshConfigKey) private var sshConfig = ""
var body: some View {
Section("SSH config") {
TextEditor(text: $sshConfig)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
Text("Extra OpenSSH config for Managed Git's connection to GitHub (host and sandbox). "
+ "Managed Git ignores your personal ~/.ssh/config to stay off your own keys and "
+ "agent, so use this to re-add only the connectivity it needs — for example, "
+ "reaching GitHub over port 443:")
.settingsCaption()
Text("Host github.com\n Hostname ssh.github.com\n Port 443")
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
.background(RoundedRectangle(cornerRadius: 4).fill(.quaternary.opacity(0.4)))
Text("IdentityFile / IdentityAgent directives are ignored — Managed Git always uses its "
+ "own key and never an ssh-agent.")
.settingsCaption()
}
}
}
/// The app-level Managed Git credential. It's always used for Nucleic's own host-side `git`
/// (cloning a project, pushing a branch, opening a PR) — pinned via SSH `IdentitiesOnly` so the
/// host never falls back to the user's personal `~/.ssh` keys — and is optionally injected into
/// sandboxed sessions ("Make available in sandbox"; Control sessions always get it). The secret
/// lives in the login Keychain (via `GitHubCredentialStore`), never in UserDefaults; only the mode
/// and flags are plain preferences. The Nucleic-managed key never surfaces its private half — only
/// its public key can be copied or exported.
private struct GitHubAccessSection: View {
@Environment(AppStore.self) private var store
@AppStorage(GitHubCredentialSettings.authModeKey) private var modeRaw = GitHubAuthMode.none.rawValue
@AppStorage(GitHubCredentialSettings.signCommitsKey) private var signCommits = false
@AppStorage(GitHubCredentialSettings.ghForceSSHKey) private var ghForceSSH = false
@AppStorage(GitHubCredentialSettings.gitForceSSHKey) private var gitForceSSH = false
@AppStorage(GitHubCredentialSettings.availableInSandboxKey) private var availableInSandbox = true
/// Draft input for the secret being entered; never pre-filled from the Keychain (we don't
/// read secrets back into the UI). `tokenStored` / `keyStored` reflect whether one is saved.
@State private var tokenDraft = ""
@State private var keyDraft = ""
/// A filesystem path to load a key from (the file-picker fills this; it can also be typed).
@State private var pathDraft = ""
/// Passphrase for an encrypted key being saved. Taken verbatim (never trimmed).
@State private var passphraseDraft = ""
@State private var tokenStored = false
@State private var keyStored = false
@State private var passphraseStored = false
/// A validation complaint about the pasted/loaded SSH key (not a real private key, or
/// encrypted-without-a-passphrase).
@State private var keyWarning: String?
/// The Nucleic-managed public key, when one has been generated; drives the `.managed` UI.
@State private var managedPublicKey: String?
/// A failure from generating the managed key.
@State private var managedError: String?
private var mode: GitHubAuthMode { GitHubAuthMode(rawValue: modeRaw) ?? .none }
/// True when Nucleic Control is in use — either turned on for new projects, or there's already
/// at least one Control project. Control sessions run in the shared container, which has no other
/// way to reach GitHub, so the credential is always made available to them; the toggle is then
/// forced on and locked.
private var controlActive: Bool {
ContainerServiceSettings.controlByDefault
|| store.projects.contains { $0.isNucleicControlled }
}
var body: some View {
Section("Managed Git") {
Picker("Authentication", selection: $modeRaw) {
Text("None").tag(GitHubAuthMode.none.rawValue)
Text("Token (HTTPS)").tag(GitHubAuthMode.token.rawValue)
Text("SSH key").tag(GitHubAuthMode.ssh.rawValue)
Text("Nucleic-managed key").tag(GitHubAuthMode.managed.rawValue)
}
Text("The credential Nucleic uses for git over GitHub — cloning a project, pushing a "
+ "branch, and opening PRs. It's pinned so the host uses only this key, never your "
+ "personal ~/.ssh keys. The secret is kept in your login Keychain.")
.settingsCaption()
switch mode {
case .none:
EmptyView()
case .token:
tokenControls
case .ssh:
sshKeyEntry
sshOptions
case .managed:
managedKeyEntry
sshOptions
}
// Whether sandboxed sessions also get this credential. Control sessions always do (their
// shared container can't reach GitHub otherwise), so it's forced on and locked then.
if controlActive {
Toggle("Make available in sandbox", isOn: .constant(true))
.disabled(true)
Text("Locked on because Nucleic Control is active: its shared container has no other "
+ "way to reach GitHub.")
.settingsCaption()
} else {
Toggle("Make available in sandbox", isOn: $availableInSandbox)
.disabled(mode == .none)
Text("Also inject this credential into sandboxed sessions, so an agent's git and gh "
+ "can reach GitHub from inside the sandbox. Off keeps it to Nucleic's own "
+ "host-side git.")
.settingsCaption()
}
}
.onAppear {
refreshStoredFlags()
managedPublicKey = ManagedSSHKey.publicKey
// Keep the stored value consistent with the locked-on display, so per-session sandboxes
// also see it as available while Control is in use.
if controlActive { availableInSandbox = true }
}
}
// MARK: Token
@ViewBuilder private var tokenControls: some View {
tokenEntry(caption: "A fine-grained or classic PAT with repo access. Injected as the "
+ "github.com git credential and as GITHUB_TOKEN for gh.")
}
/// Save/clear UI for the stored PAT, with a context-specific `caption`. Backed by the one
/// Keychain token slot, so the same secret serves token mode (git + gh) and SSH mode (gh API).
@ViewBuilder private func tokenEntry(caption: String) -> some View {
if tokenStored {
storedRow(label: "Token saved") {
GitHubCredentialStore.deleteToken()
refreshStoredFlags()
}
} else {
SecureField("Personal access token", text: $tokenDraft)
Button("Save token") {
GitHubCredentialStore.saveToken(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines))
tokenDraft = ""
refreshStoredFlags()
}
.disabled(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(caption).settingsCaption()
}
// MARK: SSH (user-supplied key)
@ViewBuilder private var sshKeyEntry: some View {
if keyStored {
storedRow(label: passphraseStored ? "SSH key saved (with passphrase)" : "SSH key saved") {
GitHubCredentialStore.deleteSSHPrivateKey()
GitHubCredentialStore.deleteSSHPassphrase()
refreshStoredFlags()
}
} else {
TextEditor(text: $keyDraft)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 90)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(.quaternary))
HStack {
Button("Choose File…") { chooseKeyFile() }
TextField("or type a key path", text: $pathDraft)
.textFieldStyle(.roundedBorder)
Button("Load") { loadKey(from: pathDraft) }
.disabled(pathDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
SecureField("Passphrase (only if the key is encrypted)", text: $passphraseDraft)
if let keyWarning {
Label(keyWarning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
Button("Save SSH key") { saveSSHKey() }
.disabled(keyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("An OpenSSH private key (e.g. ed25519) registered with GitHub. Choose its file, type "
+ "its path, or paste the key above. Passphrase-protected keys are supported — give "
+ "the passphrase so the sandbox can unlock the key non-interactively; it's kept in "
+ "your login Keychain.")
.settingsCaption()
}
private func saveSSHKey() {
let key = keyDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard key.contains("PRIVATE KEY") else {
keyWarning = "That doesn't look like an OpenSSH private key (expected a "
+ "“-----BEGIN … PRIVATE KEY-----” block)."
return
}
// Passphrases can contain meaningful whitespace, so take the field verbatim.
let passphrase = passphraseDraft
if key.contains("ENCRYPTED") && passphrase.isEmpty {
keyWarning = "This key is passphrase-protected. Enter its passphrase below so the "
+ "sandbox can unlock it non-interactively."
return
}
keyWarning = nil
GitHubCredentialStore.saveSSHPrivateKey(key)
if passphrase.isEmpty {
GitHubCredentialStore.deleteSSHPassphrase()
} else {
GitHubCredentialStore.saveSSHPassphrase(passphrase)
}
keyDraft = ""
passphraseDraft = ""
pathDraft = ""
refreshStoredFlags()
}
/// Open a file picker for a private key and load its contents into the editor. `~/.ssh` is
/// hidden, so show hidden files.
private func chooseKeyFile() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.showsHiddenFiles = true
panel.message = "Choose an OpenSSH private key"
if panel.runModal() == .OK, let url = panel.url { loadKey(from: url.path) }
}
/// Read a private key from `path` (tilde-expanded) into the editor, where the usual Save flow
/// validates it. We don't save straight from disk so the user can confirm/add a passphrase first.
private func loadKey(from path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let expanded = (trimmed as NSString).expandingTildeInPath
do {
keyDraft = try String(contentsOfFile: expanded, encoding: .utf8)
pathDraft = ""
keyWarning = nil
} catch {
keyWarning = "Couldn't read a key from \(expanded): \(error.localizedDescription)"
}
}
// MARK: Nucleic-managed key
@ViewBuilder private var managedKeyEntry: some View {
if let pub = managedPublicKey {
Label("Key generated", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Text(pub)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(6)
.background(RoundedRectangle(cornerRadius: 4).fill(.quaternary.opacity(0.4)))
HStack {
Button("Copy public key") { copyToPasteboard(pub) }
Button("Export…") { exportPublicKey(pub) }
Spacer()
Button("Regenerate", role: .destructive) { generateManagedKey() }
Button("Remove", role: .destructive) {
ManagedSSHKey.delete()
managedPublicKey = nil
}
}
} else {
Button("Generate Nucleic-managed key") { generateManagedKey() }
if let managedError {
Label(managedError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
}
Text("Nucleic generates an ed25519 SSH key and keeps the private half in your login "
+ "Keychain — it's never shown and never leaves the sandbox. Add the public key above "
+ "to GitHub (Settings → SSH and GPG keys) as an authentication key, and as a signing "
+ "key too if you enable signing below.")
.settingsCaption()
}
private func generateManagedKey() {
do {
managedPublicKey = try ManagedSSHKey.generate()
managedError = nil
} catch {
managedError = "Couldn't generate a key: \(error.localizedDescription)"
}
}
private func exportPublicKey(_ pub: String) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "nucleic-managed.pub"
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
try? (pub + "\n").write(to: url, atomically: true, encoding: .utf8)
}
}
private func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
// MARK: Shared SSH options (apply to both .ssh and .managed)
@ViewBuilder private var sshOptions: some View {
Toggle("Sign commits with this SSH key", isOn: $signCommits)
Text("Configures git in the sandbox to SSH-sign commits and tags with this key "
+ "(gpg.format=ssh, commit.gpgsign=true). Add the key as a signing key on GitHub "
+ "to get a “Verified” badge.")
.settingsCaption()
Toggle("Force gh to use SSH", isOn: $ghForceSSH)
Text("Sets the GitHub CLI's git_protocol to ssh in the sandbox, so gh's git operations "
+ "(clone, checkout, the push in gh pr create) go over this key instead of HTTPS.")
.settingsCaption()
Toggle("Restrict git to SSH for GitHub", isOn: $gitForceSSH)
Text("Rewrites https://github.com/ remotes to SSH in the sandbox, so even an HTTPS-cloned "
+ "repo pushes and fetches over this key.")
.settingsCaption()
Text("API token for gh (optional)")
.font(.callout.weight(.medium))
tokenEntry(caption: "gh authenticates its API (e.g. the metadata side of gh pr create) "
+ "with a token, not SSH — add a PAT here to let gh's API work while git stays "
+ "on SSH. Set as GITHUB_TOKEN; git never reads it.")
}
// MARK: Helpers
@ViewBuilder private func storedRow(label: String, clear: @escaping () -> Void) -> some View {
HStack {
Label(label, systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Spacer()
Button("Clear", role: .destructive, action: clear)
}
}
private func refreshStoredFlags() {
tokenStored = !(GitHubCredentialStore.loadToken() ?? "").isEmpty
keyStored = !(GitHubCredentialStore.loadSSHPrivateKey() ?? "").isEmpty
passphraseStored = !(GitHubCredentialStore.loadSSHPassphrase() ?? "").isEmpty
}
}
/// Nucleic Control: repos Nucleic clones under `~/.nucleic/control/` and manages end-to-end
/// (shared sandbox container, the `git` interceptor, autoship-eligible). Requires the container
/// service (Sandbox tab).
private struct ControlSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
@AppStorage(ContainerServiceSettings.controlByDefaultKey) private var controlByDefault = false
@AppStorage(ContainerServiceSettings.nvrsionByDefaultKey) private var nvrsionByDefault = false
@AppStorage(ContainerServiceSettings.controlContainerCPUsKey)
private var controlCPUs = ContainerServiceSettings.defaultControlContainerCPUs
@AppStorage(ContainerServiceSettings.controlContainerMemoryGiBKey)
private var controlMemoryGiB = ContainerServiceSettings.recommendedControlContainerMemoryGiB()
@AppStorage(ContainerServiceSettings.controlAuthModeKey)
private var authMode = ControlAuthMode.oauth.rawValue
@AppStorage(ContainerServiceSettings.splitControlContainersByBackendKey)
private var splitContainersByBackend = false
@AppStorage(ContainerServiceSettings.vsockControlPlaneEnabledKey)
private var vsockControlPlane = true // default-on as of sandbox image v4 (ships control-bridge.js)
@AppStorage(ContainerServiceSettings.memoryManagementKey)
private var memoryManagement = MemoryManagementLevel.conservative.rawValue
@AppStorage(ContainerServiceSettings.commandTracingEnabledKey)
private var commandTracing = false
/// Draft for the API-key entry field (write-only — the stored key is never read back into UI).
@State private var apiKeyDraft = ""
/// Whether a key is currently in the Keychain, for the "Key saved" / Clear affordances.
@State private var keySaved = ControlAPIKeyStore.hasKey
/// True while a force-recreation is in flight, to disable the button and show progress.
@State private var recreating = false
/// True while a memory-reclaiming restart is in flight.
@State private var restarting = false
/// True while an on-demand "Reclaim memory now" pass is in flight.
@State private var reclaiming = false
/// Upper bound for the memory stepper: this Mac's total physical RAM. `--memory` is a ceiling
/// the VM grows into, so allocating more than the host has would be meaningless.
private var maxControlMemoryGiB: Int {
max(1, Int(ProcessInfo.processInfo.physicalMemory / 1_073_741_824))
}
var body: some View {
Form {
Section("Nucleic Control") {
Text("Nucleic Control repos are cloned under ~/.nucleic/control/ and managed by "
+ "Nucleic — kept out of iCloud, sandboxed in one shared container, with a "
+ "`git` interceptor for reliable merge detection and file locking, and "
+ "eligible for autoship. It requires the container service.")
.settingsCaption()
if !containerServiceEnabled {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Turn on the container service in Settings → Sandbox to use Nucleic "
+ "Control.")
.font(.caption)
}
}
Toggle("Enable Nucleic Control on all projects", isOn: $controlByDefault)
.disabled(!containerServiceEnabled)
Text("New projects you clone default to Nucleic Control. You can still flip it "
+ "off for an individual clone, or convert a project later from its "
+ "settings. Requires the container service (Sandbox tab).")
.settingsCaption()
}
Section {
Text("nvrsion is a version-control system built for agentic workflows. It lets "
+ "Nucleic Control operate orders of magnitude faster and integrates with your "
+ "existing Git repos, but may complicate some workflows.")
.settingsCaption()
if !containerServiceEnabled {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Turn on the container service in Settings → Sandbox to use nvrsion.")
.font(.caption)
}
}
Toggle("Use nvrsion for new Control projects", isOn: $nvrsionByDefault)
.disabled(!containerServiceEnabled)
Text("New Nucleic Control projects start with nvrsion on. Override it per project "
+ "from its settings.")
.settingsCaption()
} header: {
HStack(spacing: 6) {
Text("nvrsion")
Text("BETA")
.font(.caption2).bold()
.padding(.horizontal, 5).padding(.vertical, 1)
.background(AppTheme.hairline, in: .capsule)
}
}
Section("Container authentication") {
Picker("Authenticate Claude with", selection: $authMode) {
Text("Claude subscription (OAuth)").tag(ControlAuthMode.oauth.rawValue)
Text("Anthropic API key").tag(ControlAuthMode.apiKey.rawValue)
}
.disabled(!containerServiceEnabled)
Text("How sessions in the shared Nucleic Control container sign Claude in. "
+ "“Claude subscription” reuses your host login; switch to “Anthropic API "
+ "key” if that login wont authenticate in the container, or to bill an API "
+ "key instead.")
.settingsCaption()
if authMode == ControlAuthMode.apiKey.rawValue {
SecureField("sk-ant-…", text: $apiKeyDraft)
.disabled(!containerServiceEnabled)
HStack(spacing: 10) {
Button("Save Key") {
if ControlAPIKeyStore.save(apiKeyDraft) { keySaved = ControlAPIKeyStore.hasKey }
apiKeyDraft = ""
}
.disabled(!containerServiceEnabled
|| apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if keySaved {
Button("Clear", role: .destructive) {
ControlAPIKeyStore.clear()
keySaved = false
}
Label("Key saved", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green).font(.caption)
}
}
Text(keySaved
? "Stored in your macOS Keychain (never on disk or in a config file) and "
+ "passed as ANTHROPIC_API_KEY inside the container. Enter a new key "
+ "above to replace it."
: "Enter an Anthropic API key (console.anthropic.com). Stored in your "
+ "macOS Keychain — never written to disk or a config file.")
.settingsCaption()
}
}
Section("Nucleic Control container") {
LabeledContent("CPUs", value: "Auto")
LabeledContent("Memory", value: "Auto")
Text("Nucleic Control container resources are controlled dynamically and cannot be set.")
.settingsCaption()
Picker("Memory management", selection: $memoryManagement) {
ForEach(MemoryManagementLevel.allCases, id: \.rawValue) { level in
Text(level.label).tag(level.rawValue)
}
}
.disabled(!containerServiceEnabled)
Text("How aggressively running sandbox VMs hand unused memory back to your Mac (via a "
+ "memory balloon). Conservative leaves the most headroom and reclaims the least; "
+ "Aggressive frees the most but is likeliest to squeeze a spiky workload. "
+ "Switching between levels takes effect within seconds. Requires the container "
+ "service.")
.settingsCaption()
Toggle("Agent Lifecycle Protection", isOn: $splitContainersByBackend)
.disabled(!containerServiceEnabled)
Text("Its actual effect is agent separation: each kind of agent gets its own sandbox "
+ "so competing agents can't kill each other's processes (\"agenticide\"). Claude, "
+ "GPT (Codex), and xAI (Grok) Control sessions each get a separate shared "
+ "container instead of sharing one. Takes effect for sessions started after the "
+ "change. Requires the container service.")
.settingsCaption()
Toggle("Control plane over vsock", isOn: $vsockControlPlane)
.disabled(!containerServiceEnabled)
Text("On by default: runs a Nucleic Control container's approval + interceptor "
+ "channel over a vsock-relayed socket instead of TCP on the VM gateway, so "
+ "macOS stops asking to allow incoming network connections / local-network "
+ "access. The agent's own internet egress still uses the gateway. Turn OFF only "
+ "to fall back to the legacy gateway-TCP path. Takes effect for containers "
+ "started after the change; requires a sandbox image with the control bridge "
+ "(v4+).")
.settingsCaption()
Toggle("Trace shell commands", isOn: $commandTracing)
.disabled(!containerServiceEnabled)
Text("Records every command the agent runs in a Bash tool call (command line, cwd, "
+ "exit code, duration) for the activity feed, via a bash trap that fires before "
+ "each command. Adds per-command shell overhead on build/test-heavy turns, so "
+ "it's off by default. The git/gh interception the conflict and merge system "
+ "relies on is unaffected. Takes effect for agent turns started after the change.")
.settingsCaption()
}
Section("Maintenance") {
Button {
reclaiming = true
Task {
await store.reclaimControlMemory()
reclaiming = false
}
} label: {
HStack(spacing: 6) {
if reclaiming { ProgressView().controlSize(.small) }
Text(reclaiming ? "Reclaiming…" : "Reclaim memory now")
}
}
.disabled(!containerServiceEnabled || reclaiming)
Text("Runs an immediate memory-reclaim pass instead of waiting for the next automatic "
+ "one — returns idle guest memory to your Mac without restarting anything.")
.settingsCaption()
Button {
restarting = true
Task {
await store.restartControlSandbox()
restarting = false
}
} label: {
HStack(spacing: 6) {
if restarting { ProgressView().controlSize(.small) }
Text(restarting ? "Restarting…" : "Restart container")
}
}
.disabled(!containerServiceEnabled || restarting || recreating)
Text("Reboots the shared Nucleic Control container in place for a full reset — clears "
+ "in-VM state and returns the VM's own runtime overhead (routine memory "
+ "reclamation is automatic). Reuses the cached rootfs and instrumentation, so "
+ "it's quick. Skips the container while a session is mid-turn — retry once idle.")
.settingsCaption()
Button(role: .destructive) {
recreating = true
Task {
await store.recreateControlSandbox()
recreating = false
}
} label: {
HStack(spacing: 6) {
if recreating { ProgressView().controlSize(.small) }
Text(recreating ? "Re-creating…" : "Force re-creation")
}
}
.disabled(!containerServiceEnabled || recreating || restarting)
Text("Deletes the shared Nucleic Control container and its sandbox image, then "
+ "rebuilds both from scratch on the next Control session — use this to pick "
+ "up a new toolchain or recover a wedged container. A Control session that's "
+ "mid-turn will lose its container and need to retry.")
.settingsCaption()
}
}
.formStyle(.grouped)
}
}
/// Everything about the chat experience: how the transcript renders, the composer
/// send key, and how completed chats are archived. The auto-archive policy syncs
/// to `AppStore` via `pushDefaults()`.
private struct ChatSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
@AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
@AppStorage(AutoArchivePolicy.storageKey) private var autoArchiveRaw = AutoArchivePolicy.fallback.rawValue
@AppStorage(ArchivedWorktreeCleanupPolicy.storageKey) private var worktreeCleanupRaw = ArchivedWorktreeCleanupPolicy.fallback.rawValue
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
private var autoArchivePolicy: AutoArchivePolicy { AutoArchivePolicy(rawValue: autoArchiveRaw) ?? .fallback }
private var worktreeCleanupPolicy: ArchivedWorktreeCleanupPolicy {
ArchivedWorktreeCleanupPolicy(rawValue: worktreeCleanupRaw) ?? .fallback
}
var body: some View {
Form {
Section("Transcript") {
Toggle("Show advanced detail", isOn: $showDebugLines)
Text("Adds debug-level lines like rate limits, token usage, and turn markers to the chat. Thinking and tool calls always show.")
.settingsCaption()
Toggle("Show lock events", isOn: $showLockEvents)
Text("Logs file-lock activity in the chat — waiting for a lock, acquiring it, and releasing it — when chats coordinate edits to the same files.")
.settingsCaption()
}
Section("Composer") {
Picker("Send with", selection: $submitKeyRaw) {
ForEach(SubmitKeyMode.allCases) { Text($0.label).tag($0.rawValue) }
}
Text(submitMode.detail)
.settingsCaption()
}
Section("Archiving") {
Picker("Auto-archive completed chats", selection: $autoArchiveRaw) {
ForEach(AutoArchivePolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
Text(autoArchivePolicy == .never
? "Completed chats stay in the sidebar until you archive them yourself."
: "Chats whose work is done move to the project's archive after \(autoArchivePolicy.durationPhrase) without activity. Favorites and the chat you have open are never auto-archived, and a chat waiting on your reply is left until you answer.")
.settingsCaption()
Picker("Delete archived chat worktrees", selection: $worktreeCleanupRaw) {
ForEach(ArchivedWorktreeCleanupPolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
Text(worktreeCleanupPolicy == .never
? "Archived chats keep their worktree on disk indefinitely."
: "Once a chat has been archived for \(worktreeCleanupPolicy.durationPhrase), its worktree is removed to free disk space. The branch and all committed work are kept (anything uncommitted is committed first), so re-opening the chat re-creates the worktree exactly.")
.settingsCaption()
}
}
.formStyle(.grouped)
.onAppear { pushDefaults() }
.onChange(of: autoArchiveRaw) { _, _ in pushDefaults() }
.onChange(of: worktreeCleanupRaw) { _, _ in pushDefaults() }
}
private func pushDefaults() {
store.autoArchiveIdleInterval = autoArchivePolicy.interval
store.archivedWorktreeCleanupInterval = worktreeCleanupPolicy.interval
}
}
/// Defaults applied to new chats (model/effort/auto/autoship), synced to `AppStore` via
/// `pushDefaults()`. The default model also picks the backend (`BackendID.forModel`): a Claude
/// SKU runs on Claude Code, a GPT SKU on Codex.
private struct AgentsSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(ModelCatalog.defaultModelKey) private var defaultModel = ModelCatalog.fallbackModel
@AppStorage(ModelCatalog.defaultEffortKey) private var defaultEffort = ModelCatalog.fallbackEffort
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
@AppStorage(ModelCatalog.defaultAutoShipKey) private var defaultAutoShip = false
@AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
@AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
/// Live install + auth state per provider, populated by an async probe on appearance and
/// re-run whenever this tab comes back into view (so logging in/out elsewhere reflects).
@State private var providers: [ProviderStatus] = []
var body: some View {
Form {
Section("New chats") {
Picker("Default model", selection: $defaultModel) {
ForEach(ModelCatalog.models, id: \.self) { sku in
(Text(ModelCatalog.displayName(sku))
+ (ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
).tag(sku)
}
}
Picker("Default \(ModelCatalog.effortNoun(for: defaultModel).lowercased())", selection: $defaultEffort) {
ForEach(ModelCatalog.efforts(for: defaultModel), id: \.self) { Text(ModelCatalog.effortDisplayName($0)).tag($0) }
}
Toggle("Start new chats in Auto mode", isOn: $defaultAuto)
Text("Auto mode lets the agent auto-approve safe actions; destructive ones still ask.")
.settingsCaption()
Toggle("Autoship new chats", isOn: $defaultAutoShip)
Text("Autoship merges a finished chat's branch back into the project automatically. It implies Auto mode, and only takes effect in Nucleic Control projects.")
.settingsCaption()
}
Section("Backend") {
ForEach(providers) { provider in
HStack(spacing: 8) {
Circle()
.fill(provider.isConnected ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(provider.name)
Spacer()
Text(provider.isConnected ? "Connected" : "Not Connected")
.foregroundStyle(.secondary)
}
.help(provider.detail)
}
Text("The chosen model selects the agent: Claude models run on Claude Code, GPT models on Codex (codex app-server), and Grok models on Grok over ACP (grok agent stdio). Pick any from the composer's model menu. Grok runs host-only and authenticates via the grok login or XAI_API_KEY.")
.settingsCaption()
}
.task { providers = await ProviderAvailability.probeAll() }
Section("Service status") {
Picker("Show status indicator", selection: $statusVisibilityRaw) {
ForEach(StatusIndicatorVisibility.allCases) { Text($0.label).tag($0.rawValue) }
}
Toggle("Show in chat during an incident on the current model's provider", isOn: $showStatusInChatOnIncident)
.disabled(StatusIndicatorVisibility(rawValue: statusVisibilityRaw) == .everywhere)
Text("Surfaces the indicator beside a chat's composer when that chat's model's provider has an active incident, even when the indicator is set to “Only on home view.”")
.settingsCaption()
ForEach(StatusProvider.allCases) { provider in
Toggle("Monitor \(provider.displayName)", isOn: providerBinding(provider))
}
Text("Watch each provider's status page for incidents on the components you rely on. Turn off providers you don't use so their incidents aren't polled or shown.")
.settingsCaption()
}
}
.formStyle(.grouped)
.onAppear {
defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
pushDefaults()
}
.onChange(of: defaultModel) { _, _ in
// A new default model may not support the stored effort (e.g. switching to Codex
// drops "max" → "xhigh").
defaultEffort = ModelCatalog.clampedEffort(defaultEffort, for: defaultModel)
pushDefaults()
}
.onChange(of: defaultEffort) { _, _ in pushDefaults() }
.onChange(of: defaultAuto) { _, _ in pushDefaults() }
.onChange(of: defaultAutoShip) { _, _ in
if defaultAutoShip { defaultAuto = true } // shipping implies autonomous
pushDefaults()
}
}
private func pushDefaults() {
store.defaultModel = defaultModel
store.defaultEffort = defaultEffort
store.defaultAuto = defaultAuto
store.defaultAutoShip = defaultAutoShip
}
/// Toggle binding for one provider's status monitoring: reads/writes the live store set
/// (so the row reflects state immediately) and persists the choice for next launch.
private func providerBinding(_ provider: StatusProvider) -> Binding<Bool> {
Binding(
get: { store.enabledStatusProviders.contains(provider) },
set: { isOn in
UserDefaults.standard.set(isOn, forKey: StatusIndicatorSettings.providerKey(provider))
var enabled = store.enabledStatusProviders
if isOn { enabled.insert(provider) } else { enabled.remove(provider) }
store.setEnabledStatusProviders(enabled)
})
}
}
/// Apple Intelligence model choice (used for summaries and auto-named chats), the
/// AI tool-call summarization options that depend on it, and its live availability
/// status. Summarization preferences sync to `AppStore` via `pushDefaults()`.
private struct IntelligenceSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(IntelligenceChoice.storageKey) private var choiceRaw = IntelligenceChoice.onDevice.rawValue
@AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
@AppStorage(TranscriptDisplay.toolSummaryScopeKey) private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
private var choice: IntelligenceChoice { IntelligenceChoice(rawValue: choiceRaw) ?? .onDevice }
private var toolSummaryScope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }
var body: some View {
Form {
Section("Model") {
Picker("Model", selection: $choiceRaw) {
ForEach(IntelligenceChoice.allCases) { option in
// Energy Aware switches on AC vs. battery, so it's meaningless on a
// desktop — gray it out there (desktops have no battery to detect).
Text(option.label)
.tag(option.rawValue)
.disabled(option == .energyAware && !PowerSource.hasBattery)
}
}
.pickerStyle(.radioGroup)
Text(choice.detail)
.settingsCaption()
}
Section("Tool call summaries") {
Toggle("Summarize tool calls with AI", isOn: $summarizeToolCalls)
Text(summarizeToolCalls
? "Consecutive calls always merge into one line per tool (\u{201C}Read a, b from dir/\u{201D}); with this on, eligible lines are rephrased by the on-device model. Tap a block to expand the individual calls."
: "Consecutive calls still merge into one line per tool (\u{201C}Read a, b from dir/\u{201D}) — turn this on to let the on-device model rephrase them.")
.settingsCaption()
if summarizeToolCalls {
Picker("AI summarizes", selection: $toolSummaryScopeRaw) {
Text("Bash commands only").tag(ToolSummaryScope.bashOnly.rawValue)
Text("All tool calls").tag(ToolSummaryScope.all.rawValue)
}
Text("Read calls always collapse to a plain merged list (\u{201C}Read x, y, z\u{201D}) and are never AI-summarized.")
.settingsCaption()
if toolSummaryScope == .all {
Label {
Text("Summarizing every tool call runs the on-device model far more often — heavy work best suited to an M4 Pro or stronger chip. \(DeviceCapability.heavySummaryAdvice)")
} icon: {
Image(systemName: DeviceCapability.handlesHeavySummaries
? "checkmark.seal" : "exclamationmark.triangle.fill")
}
.settingsCaption()
.foregroundStyle(DeviceCapability.handlesHeavySummaries
? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange))
}
}
}
Section("Status") {
LabeledContent("On-device model", value: IntelligenceAvailability.status(for: .onDevice))
LabeledContent("Private Cloud Compute", value: IntelligenceAvailability.status(for: .privateCloudCompute))
if PowerSource.hasBattery {
LabeledContent("Energy Aware", value: IntelligenceAvailability.status(for: .energyAware))
}
}
.font(.callout)
}
.formStyle(.grouped)
.onAppear { pushDefaults() }
.onChange(of: summarizeToolCalls) { _, _ in pushDefaults() }
.onChange(of: toolSummaryScopeRaw) { _, _ in pushDefaults() }
}
private func pushDefaults() {
store.summarizeToolCalls = summarizeToolCalls
store.toolSummaryScope = toolSummaryScope
}
}
/// iPhone pairing and the LAN sync server (host side).
private struct RemoteSettingsTab: View {
var body: some View {
Form {
RemoteAccessSection()
}
.formStyle(.grouped)
}
}
private extension View {
/// Shared styling for the small explanatory captions beneath settings controls.
func settingsCaption() -> some View {
self.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}