Files
nucleic/Sources/NucleicApp/SettingsView.swift
T

2735 lines
130 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 UniformTypeIdentifiers
import NucleicCore
import NucleicProtocol
/// App settings. A source-list sidebar groups the panes into sections — Interface, Agents & AI,
/// Environments, Connectivity — so the panel stays legible and keeps scaling as areas mature. It
/// replaces the original horizontal tab bar, which had run out of room at nine tabs (and dated from
/// when there were only a few). Each pane is a self-contained `Form`; the sidebar just picks which
/// one the detail column shows, and the choice is remembered across launches.
struct SettingsView: View {
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
/// Persists the last-open pane so reopening Settings returns you where you left off.
@AppStorage("nucleic.settings.selectedPane") private var storedPane = SettingsPane.general.rawValue
/// Starts unset so the first `onAppear` assignment restores `storedPane` — resolving it inline in
/// the initializer would fight the sidebar's selection binding on first render.
@State private var selection: SettingsPane?
/// Pins the sidebar open: sets `canCollapse = false` on the AppKit split-view item so the column
/// never collapses on its own when the window narrows (see `SidebarColumnController`). Paired with
/// `.toolbar(removing: .sidebarToggle)` below, which drops the manual collapse button — together
/// they make the sidebar impossible to hide.
// The main-app sidebar is 306pt; Settings needs 30% less width (214.2pt).
@State private var sidebarColumn = SidebarColumnController(
fixedWidth: SidebarColumnController.defaultFixedWidth * 0.7)
private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }
var body: some View {
NavigationSplitView {
List(selection: $selection) {
// Emit each Section explicitly rather than through a `ForEach` over the groups:
// on macOS a sidebar `List` whose top-level `ForEach` yields `Section`s drops
// alternating sections, so the odd groups (Agents & AI, Connectivity) rendered as
// empty headers. Spelling out the sections sidesteps that; adding a future group is
// still a one-liner here plus a case in `SettingsGroup`.
sidebarSection(.interface)
sidebarSection(.agents)
sidebarSection(.environments)
sidebarSection(.connectivity)
}
// Fixed sidebar width: 30% narrower than the main-app sidebar. Equal min/ideal/max sets the
// SwiftUI hint; the hard pin (non-draggable divider) is enforced in AppKit via
// `sidebarColumn.fixedWidth`.
.navigationSplitViewColumnWidth(
min: sidebarColumn.fixedWidth,
ideal: sidebarColumn.fixedWidth,
max: sidebarColumn.fixedWidth)
// Settings should always show its sidebar: drop the manual collapse control, and pin the
// AppKit split-view item open so a narrow window can't auto-collapse it either.
.toolbar(removing: .sidebarToggle)
.background { SidebarColumnConfigurator(controller: sidebarColumn) }
} detail: {
detailPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Match the main window's detail surface. Each pane is a grouped `Form`, whose
// default scroll background is the system window gray (a warmer, lighter tone than
// our theme). Hide that and paint the app's own `AppTheme.background` so the Settings
// pane reads identically to the normal window's detail column (RootView paints the
// same fill there).
.scrollContentBackground(.hidden)
.background(AppTheme.background)
}
.navigationSplitViewStyle(.balanced)
// Fixed overall width so the settings window can't be resized narrow (which is what
// drove the sidebar's auto-collapse). The narrower sidebar leaves more room for detail. Height stays
// flexible.
.frame(minWidth: 840, idealWidth: 840, maxWidth: 840, minHeight: 560, maxHeight: .infinity)
.preferredColorScheme(appearance.colorScheme)
.dynamicTypeSize(textSize.dynamicTypeSize)
.background(SettingsWindowConfigurator(reapplyToken: (selection ?? .general).rawValue))
.onAppear { if selection == nil { selection = SettingsPane(rawValue: storedPane) ?? .general } }
.onChange(of: selection) { _, pane in if let pane { storedPane = pane.rawValue } }
}
/// One titled sidebar section listing its group's panes as selectable rows.
private func sidebarSection(_ group: SettingsGroup) -> some View {
Section(group.title) {
ForEach(group.panes) { pane in
Label(pane.title, systemImage: pane.symbol).tag(pane)
}
}
}
/// The content column for the current selection. Each case is an existing `Form`-based pane view.
@ViewBuilder private var detailPane: some View {
switch selection ?? .general {
case .general: GeneralSettingsTab()
case .appearance: AppearanceSettingsTab()
case .notifications: NotificationSettingsTab()
case .chats: ChatSettingsTab()
case .agents: AgentsSettingsTab()
case .usage: UsageSettingsTab()
case .intelligence: IntelligenceSettingsTab()
case .sandbox: SandboxSettingsTab()
case .virtualMachines: MacVMSettingsTab()
case .control: ControlSettingsTab()
case .git: GitSettingsTab()
case .covalence: RemoteSettingsTab()
}
}
}
/// One selectable pane in the Settings sidebar. Raw values are stable (they back the persisted
/// last-open pane) and need not be contiguous, so panes can be reordered or inserted freely.
private enum SettingsPane: Int, Identifiable, CaseIterable, Hashable {
case general = 0
case appearance = 1
case notifications = 2
case chats = 3
case agents = 4
case intelligence = 5
case sandbox = 6
case virtualMachines = 7
case control = 8
case git = 9
case covalence = 10
case usage = 11
var id: Int { rawValue }
var title: String {
switch self {
case .general: "General"
case .appearance: "Appearance"
case .notifications: "Notifications"
case .chats: "Chats"
case .agents: "Agents"
case .intelligence: "Intelligence"
case .sandbox: "Sandbox"
case .virtualMachines: "Virtual Machines"
case .control: "Control"
case .git: "Git"
case .covalence: "Covalence"
case .usage: "Usage"
}
}
var symbol: String {
switch self {
case .general: "gearshape"
case .appearance: "paintbrush"
case .notifications: "bell.badge"
case .chats: "bubble.left.and.bubble.right"
case .agents: "cpu"
case .intelligence: "sparkles"
case .sandbox: "shippingbox"
case .virtualMachines: "desktopcomputer"
case .control: "lock.shield"
case .git: "arrow.triangle.branch"
case .covalence: "circle.dotted.and.circle"
case .usage: "gauge.with.dots.needle.67percent"
}
}
}
/// A titled section in the sidebar. Declaration order here is the on-screen order of both the
/// sections and the panes within them.
private enum SettingsGroup: Int, Identifiable, CaseIterable {
case interface
case agents
case environments
case connectivity
var id: Int { rawValue }
var title: String {
switch self {
case .interface: "Interface"
case .agents: "Agents & AI"
case .environments: "Environments"
case .connectivity: "Connectivity"
}
}
var panes: [SettingsPane] {
switch self {
case .interface: [.general, .appearance, .notifications, .chats]
case .agents: [.agents, .usage, .intelligence]
case .environments: [.sandbox, .virtualMachines, .control]
case .connectivity: [.git, .covalence]
}
}
}
/// 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)
}
}
}
/// Appearance: the theme, text size, and color-vision accommodation. Split into its own pane (it
/// used to sit atop the General tab) so the sidebar has a dedicated, discoverable home for how the
/// app looks — the conventional place a mature macOS app keeps these.
private struct AppearanceSettingsTab: 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(UltraGlass.enabledKey) private var ultraGlass = false
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) }
}
}
Section("Materials") {
Toggle("Full Glass", isOn: $ultraGlass)
Text("Full Glass mode replaces most opaque surfaces with the system glass material.")
.font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
/// Notifications: how a chat calls you back when it finishes or needs you — completion and blocked
/// sounds, the escalating alarm, and Dock bouncing. Gathered from the old General tab into one pane
/// since they're all facets of the same "get my attention" behavior.
private struct NotificationSettingsTab: View {
@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
var body: some View {
Form {
Section("Sounds") {
Toggle("Play a sound when a chat finishes", isOn: $playDoneSound)
Toggle("Play a sound when a chat needs you", isOn: $playBlockedSound)
Toggle("Escalating alarm until you return", isOn: $escalatingAlarm)
.onChange(of: escalatingAlarm) { _, isOn in
if !isOn { ChatAlarm.shared.setActive(false) } // silence any alarm in progress
}
}
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
}
}
}
.formStyle(.grouped)
}
}
/// General: app-wide behavior that doesn't belong to a more specific pane — the home streak, power
/// (sleep) management, to-do labeling, diagnostics, privacy, updates, and the version footer.
/// Appearance and notification behaviors now have their own panes, leaving this one focused.
private struct GeneralSettingsTab: View {
@AppStorage(StreakBadge.showKey) private var showStreak = true
@AppStorage(SleepBlocker.enabledKey) private var blockSleep = false
@State private var sleepBlocker = SleepBlocker.shared
@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("Home") {
Toggle("Show streak counter", isOn: $showStreak)
}
Section("Power") {
Toggle("Smart Sleep", isOn: Binding(
get: { blockSleep },
set: { isOn in
blockSleep = isOn
sleepBlocker.setEnabled(isOn)
}))
Text("Blocks sleep while agents are running, uses DarkWake when no agents are "
+ "running to preserve power while staying up-to-date.")
.settingsCaption()
if blockSleep, sleepBlocker.requiresHelperApproval {
Button("Approve Smart Sleep Helper…") {
sleepBlocker.openHelperApprovalSettings()
}
Text("In System Settings, go to General > Login Items & Extensions, then turn "
+ "on Nucleic under Allow in the Background.")
.settingsCaption()
}
if blockSleep, let error = sleepBlocker.lastError {
Text(error)
.settingsCaption()
.foregroundStyle(.red)
}
}
Section("To-dos") {
Toggle("Encouraging to-do labels", isOn: $encouragingTriageLabels)
}
Section("Diagnostics") {
Toggle("Smart Move logging", isOn: $smartMoveLogging)
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)
}
Section("Singularity preparation") {
Toggle("Prepare for the singularity", isOn: $singularityPreparation)
}
// 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()
}
}
}
// 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 863)" — the marketing version (`CFBundleShortVersionString`),
/// the commit the binary was built from, and the monotonic `CFBundleVersion` build number,
/// all resolved once by `BuildInfo`.
private static var versionFooter: String {
let info = BuildInfo.current
let build = [info.buildLabel, info.buildNumber].compactMap { $0 }.joined(separator: " ")
return "Version \(info.version ?? "—") (build \(build))"
}
/// 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.hostExecEnabledKey) private var hostExecEnabled = true
@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)
LearnMoreLink("How agent sandboxing works", url: SupportURL.sandboxes)
if store.containerServiceLockedOn {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
Text("Required by your Nucleic Control projects. 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)
}
Section("Host access") {
Toggle(isOn: $hostExecEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text("Allow the host_exec tool")
Text("Lets sandboxed agents request to run commands on the host machine "
+ "(outside the container) — e.g. building or running a macOS binary. "
+ "Each call still needs your explicit approval. Turn this off to remove "
+ "the escape hatch entirely: the tool is never offered and the agent is "
+ "never told it exists, regardless of a project's \"Allow host build/run\" "
+ "setting.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.disabled(!containerServiceEnabled)
}
// 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)
}
.disabled(true)
.opacity(0.5)
}
.formStyle(.grouped)
}
}
/// The Virtual Machines tab: the macOS-guest VM service (Apple `Virtualization`). Enables the
/// `mac_vm_exec` tool — each agent session gets its own isolated macOS VM for Xcode/simulator work,
/// the per-agent alternative to the shared-host `host_exec` (see docs/MACOS_VM.md). Also drives the
/// one-time golden base-image build and shows the live VMs.
private struct MacVMSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(MacVMSettings.serviceEnabledKey) private var serviceEnabled = false
@AppStorage(MacVMSettings.exposeByDefaultKey) private var exposeByDefault = true
@AppStorage(MacVMSettings.computerUseByDefaultKey) private var computerUseByDefault = true
@AppStorage(MacVMSettings.axAgentEnabledKey) private var axAgentEnabled = false
@AppStorage(MacVMSettings.autoOpenVMMonitorsKey) private var autoOpenVMMonitors = false
@AppStorage(MacVMSettings.pictureInPictureVMMonitorsKey) private var pipVMMonitors = true
@AppStorage(MacVMSettings.restoreImageURLKey) private var restoreImageURL = ""
@AppStorage(MacVMSettings.restoreImageChoiceKey) private var restoreImageChoice = "macos27-devbeta"
@AppStorage(MacVMSettings.vmCPUsKey) private var vmCPUs = MacVMSettings.defaultVMCPUs
@AppStorage(MacVMSettings.vmMemoryGiBKey) private var vmMemoryGiB = MacVMSettings.defaultVMMemoryGiB
@AppStorage(MacVMSettings.maxConcurrentVMsKey)
private var maxConcurrent = MacVMSettings.defaultMaxConcurrentVMs
// Linux VM service (a separate, independently-gated guest OS on the same Virtualization stack).
@AppStorage(MacVMSettings.linuxServiceEnabledKey) private var linuxServiceEnabled = false
@AppStorage(MacVMSettings.linuxExposeByDefaultKey) private var linuxExposeByDefault = true
@AppStorage(MacVMSettings.linuxComputerUseByDefaultKey) private var linuxComputerUseByDefault = true
@AppStorage(MacVMSettings.linuxAxAgentEnabledKey) private var linuxAxAgentEnabled = false
// Cross-guest computer-use default + the agent-container tool gate (a container-service setting,
// surfaced here alongside the VM controls so all virtualization knobs live together).
@AppStorage(MacVMSettings.defaultComputerUseVMTypeKey)
private var defaultComputerUseVMType = ComputerUseVMType.linux.rawValue
@AppStorage(ContainerServiceSettings.agentContainersEnabledKey)
private var agentContainersEnabled = true
@AppStorage(ContainerServiceSettings.serviceEnabledKey) private var containerServiceEnabled = false
@State private var baseProgress: MacVMBaseProgress?
/// Ordered list of build phases observed so far in the current build, so completed steps stay
/// on screen as a checklist and the whole thing visibly advances (rather than one lone spinner
/// that reads as "frozen" during a long indeterminate phase). Reset whenever no build is running.
@State private var seenBuildPhases: [MacVMBaseProgress.Phase] = []
@State private var runningVMs: [MacVMEntry] = []
@State private var baseOSVersion: String?
@State private var baseStatus: MacVMBaseStatus?
@State private var buildError: String?
@State private var building = false
@State private var deleting = false
// Linux base build/state (the build-progress bar `baseProgress` is shared — only one base builds
// at a time — and routed to whichever section started it via `linuxBuilding`).
@State private var linuxBaseStatus: MacVMBaseStatus?
@State private var linuxBuildError: String?
@State private var linuxBuilding = false
@State private var linuxDeleting = false
@State private var confirmingLinuxDelete = false
/// Drives the "Delete base image" destructive confirmation dialog.
@State private var confirmingBaseDelete = false
/// Whether the base-build diagnostic monitor window is currently shown.
@State private var observingBaseVM = false
/// Whether the (design-only) Linux "Packages…" panel is presented.
@State private var showingPackages = false
/// Host paths of the user-picked `.app` bundles to bake into the base image's `/Applications`
/// (Settings → "Included apps"). Loaded from / written back to `MacVMSettings.bundledAppPaths`.
@State private var bundledApps: [String] = []
/// Whether an app bundle is currently being dragged over the "Included apps" drop zone.
@State private var appDropTargeted = false
/// True while a "Copy to running VMs" push is in flight (disables the button).
@State private var pushingApps = false
/// The outcome of the last "Copy to running VMs" push, shown beneath the button.
@State private var appPushStatus: String?
/// True while an "Add to base image" injection (boot base → copy → power off) is in flight.
@State private var addingToBase = false
/// The outcome of the last "Add to base image" injection, shown beneath the button.
@State private var baseAddStatus: String?
/// Ids of the common packages (``MacVMPackage``) opted into installing in the base image (Settings →
/// "Common packages"). Loaded from / written back to `MacVMSettings.selectedPackageIDs`.
@State private var selectedPackageIDs: Set<String> = []
/// True while a "Copy to running VMs" package push is in flight (disables the button).
@State private var pushingPackages = false
/// The outcome of the last package "Copy to running VMs" push, shown beneath the button.
@State private var packagePushStatus: String?
/// True while an "Add to base image" package injection is in flight.
@State private var addingPackagesToBase = false
/// The outcome of the last package "Add to base image" injection, shown beneath the button.
@State private var packageAddStatus: String?
private var supported: Bool { store.macVMSupported }
/// True when every configured "Included app" has already been staged into the installed base image
/// — either by a prior Import or automatically during base provisioning (both record the app's path
/// in ``MacVMBaseStatus/installedAppPaths``). With nothing left to import, the Import button greys
/// out. `false` when there are no configured apps (there's simply nothing to import).
private var allIncludedAppsImported: Bool {
guard let baseStatus, !bundledApps.isEmpty else { return false }
let imported = Set(baseStatus.installedAppPaths)
return bundledApps.allSatisfy { imported.contains($0) }
}
/// True while at least one of the two guest services is enabled — the gate for the shared
/// session-default and resource controls, which are meaningless with no VM to run.
private var anyServiceEnabled: Bool { serviceEnabled || linuxServiceEnabled }
/// Single pane covering both guest OSes (macOS, Linux) and the knobs shared by both. The top of
/// the pane holds the cross-guest switches — which engines are on, how the agent may drive them,
/// and session defaults — so the per-platform sections below can focus purely on base images and
/// the apps/packages baked into them.
var body: some View {
Form {
virtualizationEngineSection
computerUseSection
macOSSection
linuxSection
resourcesSection
runningNowSection
}
.formStyle(.grouped)
.task { await poll() }
.onReceive(NotificationCenter.default.publisher(for: .macVMObserverWindowClosed)) { note in
// The user closed the base-build diagnostic monitor window directly (red X) instead of via
// "Hide VM screen" — flip the toggle back to "Show" and clear the engine's remembered
// request so it doesn't pop back. (Ignore closes of any other surface's viewer.)
guard note.object as? String == MacVMEngine.baseProvisionSurfaceName, observingBaseVM
else { return }
observingBaseVM = false
Task { await store.setMacVMBaseObserver(visible: false) }
}
.onAppear {
reconcileBaseImageChoice()
bundledApps = MacVMSettings.bundledAppPaths
selectedPackageIDs = Set(MacVMSettings.selectedPackageIDs)
// Exposing a session's own VMs is now always-on — normalize any stored opt-out.
exposeByDefault = true
linuxExposeByDefault = true
}
.sheet(isPresented: $showingPackages) { LinuxPackagesSheet() }
}
// MARK: - Virtualization engine (which guests are available)
@ViewBuilder private var virtualizationEngineSection: some View {
Section("Virtualization engine") {
Toggle("macOS", isOn: $serviceEnabled)
.disabled(!supported)
Toggle("Linux", isOn: $linuxServiceEnabled)
.disabled(!supported)
if !supported {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Virtual machines require an Apple silicon Mac.").font(.caption)
}
} else {
LearnMoreLink(url: SupportURL.virtualMachines)
}
}
}
// MARK: - Shared session defaults
/// True when both guests are set to allow kernel-level use — the "on" state of the merged
/// "Kernel-level use" toggle, and the gate for whether "Semantic understanding" is shown.
private var kernelUseBoth: Bool { computerUseByDefault && linuxComputerUseByDefault }
/// Binding for the merged "Kernel-level use" toggle. Writes both guests together; turning it off
/// also clears both guests' semantic flag, which builds on kernel-level use.
private var kernelUseBothBinding: Binding<Bool> {
Binding(
get: { kernelUseBoth },
set: {
computerUseByDefault = $0
linuxComputerUseByDefault = $0
if !$0 { axAgentEnabled = false; linuxAxAgentEnabled = false }
})
}
/// Binding for the merged "Semantic understanding" toggle (both guests written together).
private var semanticBothBinding: Binding<Bool> {
Binding(
get: { axAgentEnabled && linuxAxAgentEnabled },
set: { axAgentEnabled = $0; linuxAxAgentEnabled = $0 })
}
// MARK: - macOS (base image + included apps)
@ViewBuilder private var macOSSection: some View {
Section("macOS virtual machines") {
if let baseOSVersion {
LabeledContent("Base image", value: "macOS \(baseOSVersion)")
}
if building, baseProgress == nil, !linuxBuilding {
// `building` flips true synchronously on click, but the engine's first progress
// phase can be up to ~a minute out — it loads/validates the cached (~14 GB) restore
// image, or stages the provisioning share and boots the guest, before publishing a
// phase. Without this the panel just shows the greyed-out "…" menu and the action
// feels dead; a spinner bridges the gap until the real phase checklist takes over.
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Starting base image build…").settingsCaption()
}
} else if let baseProgress, !linuxBuilding {
VStack(alignment: .leading, spacing: 8) {
ForEach(seenBuildPhases, id: \.self) { phase in
buildStageRow(
phase, os: .macOS,
active: phase == baseProgress.phase && phase != .ready,
fraction: baseProgress.fraction,
detail: phase == baseProgress.phase ? baseProgress.detail : nil)
}
}
Text("This can take a while — installing and provisioning macOS downloads several "
+ "gigabytes and reboots the guest a few times. You can leave this and keep working; "
+ "it runs in the background.")
.settingsCaption()
// Once the build reaches `.ready` the provisioning VM has already powered off and its
// surface detached (there's no live screen left to show), so drop the toggle entirely.
if baseProgress.phase != .ready {
Button {
Task {
observingBaseVM.toggle()
await store.setMacVMBaseObserver(visible: observingBaseVM)
}
} label: {
Label(
observingBaseVM ? "Hide VM screen" : "Show VM screen",
systemImage: observingBaseVM ? "eye.slash" : "eye")
}
}
} else if let baseStatus, baseStatus.installed {
// Built: the status line reads as "Ready — …", with the destructive/rebuild actions
// tucked into a trailing "…" menu so the settled state stays uncluttered.
HStack {
baseStatusView(baseStatus)
Spacer()
Menu {
Button {
Task { await runBaseBuild() }
} label: {
Label("Rebuild / re-provision base image", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
confirmingBaseDelete = true
} label: {
Label("Delete base image…", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.disabled(!supported || building || deleting)
}
} else {
Button {
Task { await runBaseBuild() }
} label: {
Label("Build base image", systemImage: "square.and.arrow.down.on.square")
.padding(.vertical, 4)
}
.disabled(!supported || building || deleting)
LearnMoreLink("How base-image builds work", url: SupportURL.macVMBaseImage)
}
if let buildError {
Text(buildError).font(.caption).foregroundStyle(.red)
}
Picker("Base macOS version", selection: $restoreImageChoice) {
ForEach(Self.ipswCatalog) { choice in
Text(choice.label).tag(choice.id)
}
}
.onChange(of: restoreImageChoice) { _, newValue in
applyIPSWChoice(newValue)
}
includedAppsControls
commonPackagesControls
}
.disabled(!serviceEnabled || !supported)
.confirmationDialog(
"Delete the installed base image?", isPresented: $confirmingBaseDelete,
titleVisibility: .visible
) {
Button("Delete & reinstall (keep download)", role: .destructive) {
Task { await deleteBaseImage(includingRestoreImages: false) }
}
Button("Delete & re-download everything", role: .destructive) {
Task { await deleteBaseImage(includingRestoreImages: true) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Removes the installed macOS base so the next build reinstalls from scratch — "
+ "unlike Rebuild, which only re-provisions the existing install. Keep the cached "
+ "~14 GB restore image for a faster reinstall, or re-download it too for a fully "
+ "clean slate. Running sessions and any configured prebuilt base are unaffected.")
}
}
/// The "Included apps" controls — a drop zone plus the import/copy actions — merged inline into
/// the macOS section. Dropped `.app` bundles are baked into the base image.
@ViewBuilder private var includedAppsControls: some View {
VStack(alignment: .leading, spacing: 10) {
if !bundledApps.isEmpty {
ForEach(bundledApps, id: \.self) { path in
includedAppRow(path)
}
}
HStack {
Button {
chooseApps()
} label: {
Label("Add app…", systemImage: "plus")
}
if let baseStatus, baseStatus.installed {
Button {
Task { await addAppsToBase() }
} label: {
Label(
addingToBase ? "Importing…" : "Import",
systemImage: "square.and.arrow.down.on.square")
}
.disabled(
addingToBase || building || deleting || bundledApps.isEmpty
|| allIncludedAppsImported)
}
if !bundledApps.isEmpty {
Spacer()
Text("^[\(bundledApps.count) app](inflect: true)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
appDropTargeted ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear),
style: StrokeStyle(lineWidth: 2, dash: [5]))
)
.dropDestination(for: URL.self) { urls, _ in
addApps(urls)
} isTargeted: { appDropTargeted = $0 }
if let baseAddStatus {
Text(baseAddStatus).font(.caption).foregroundStyle(.secondary)
}
if !runningVMs.isEmpty {
Button {
Task { await pushAppsToRunningVMs() }
} label: {
HStack(spacing: 6) {
if pushingApps { ProgressView().controlSize(.small) }
Label(
"Copy to \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
systemImage: "arrow.down.app")
}
}
.disabled(pushingApps || bundledApps.isEmpty)
if let appPushStatus {
Text(appPushStatus).font(.caption).foregroundStyle(.secondary)
}
}
}
/// The "Common packages" controls — a checklist of curated tools the guest fetches and installs
/// itself (e.g. the latest Chrome), plus the same import/copy actions as "Included apps". Entries
/// the platform can't automate yet (``MacVMPackage/available`` == false) show grayed out with a
/// note, so the UI advertises what's coming without letting it be selected.
@ViewBuilder private var commonPackagesControls: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Common packages")
.font(.callout.weight(.medium))
Text("Tools the VM downloads and installs itself — no bundle to supply.")
.font(.caption).foregroundStyle(.secondary)
ForEach(MacVMPackage.catalog) { pkg in
commonPackageRow(pkg)
}
HStack {
if let baseStatus, baseStatus.installed {
Button {
Task { await addPackagesToBase() }
} label: {
Label(
addingPackagesToBase ? "Importing…" : "Import",
systemImage: "square.and.arrow.down.on.square")
}
.disabled(addingPackagesToBase || building || deleting || selectedPackageIDs.isEmpty)
}
if !selectedPackageIDs.isEmpty {
Spacer()
Text("^[\(selectedPackageIDs.count) package](inflect: true) selected")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
if let packageAddStatus {
Text(packageAddStatus).font(.caption).foregroundStyle(.secondary)
}
if !runningVMs.isEmpty {
Button {
Task { await pushPackagesToRunningVMs() }
} label: {
HStack(spacing: 6) {
if pushingPackages { ProgressView().controlSize(.small) }
Label(
"Install in \(runningVMs.count) running VM\(runningVMs.count == 1 ? "" : "s")",
systemImage: "arrow.down.app")
}
}
.disabled(pushingPackages || selectedPackageIDs.isEmpty)
if let packagePushStatus {
Text(packagePushStatus).font(.caption).foregroundStyle(.secondary)
}
}
}
/// One row in the "Common packages" checklist: a toggle bound to the selection for installable
/// packages, or a disabled row with the "coming soon" note for placeholders.
@ViewBuilder private func commonPackageRow(_ pkg: MacVMPackage) -> some View {
Toggle(isOn: Binding(
get: { selectedPackageIDs.contains(pkg.id) },
set: { on in
if on { selectedPackageIDs.insert(pkg.id) } else { selectedPackageIDs.remove(pkg.id) }
MacVMSettings.setSelectedPackageIDs(MacVMPackage.catalog.map(\.id)
.filter { selectedPackageIDs.contains($0) })
})
) {
VStack(alignment: .leading, spacing: 1) {
Text(pkg.name).font(.callout)
let subtext = pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary)
if !subtext.isEmpty {
Text(subtext)
.font(.caption2).foregroundStyle(.secondary)
}
}
}
.toggleStyle(.checkbox)
.disabled(!pkg.isInstallable)
}
// MARK: - Linux guest sections
@ViewBuilder private var linuxSection: some View {
Section("Linux virtual machines") {
if let s = linuxBaseStatus, s.installed {
LabeledContent("Base image", value: linuxDistroLabel(s))
}
if let baseProgress, linuxBuilding {
VStack(alignment: .leading, spacing: 8) {
ForEach(seenBuildPhases, id: \.self) { phase in
buildStageRow(
phase, os: .linux,
active: phase == baseProgress.phase && phase != .ready,
fraction: baseProgress.fraction,
detail: phase == baseProgress.phase ? baseProgress.detail : nil)
}
}
Text("This can take a while — downloading and assembling the Linux base, then "
+ "installing the desktop and toolchain. It runs in the background; you can keep "
+ "working.")
.settingsCaption()
} else if let s = linuxBaseStatus, s.installed {
HStack {
linuxBaseStatusView(s)
Spacer()
Menu {
Button {
Task { await runLinuxBaseBuild() }
} label: {
Label("Rebuild Linux base image", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
confirmingLinuxDelete = true
} label: {
Label("Delete Linux base image…", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.disabled(!supported || linuxBuilding || linuxDeleting)
}
} else {
Button {
Task { await runLinuxBaseBuild() }
} label: {
Label("Build Linux base image", systemImage: "square.and.arrow.down.on.square")
.padding(.vertical, 4)
}
.disabled(!linuxServiceEnabled || !supported || linuxBuilding || linuxDeleting || building)
}
if let linuxBuildError {
Text(linuxBuildError).font(.caption).foregroundStyle(.red)
}
Button {
showingPackages = true
} label: {
Label("Packages…", systemImage: "shippingbox")
}
}
.disabled(!linuxServiceEnabled || !supported)
.confirmationDialog(
"Delete the installed Linux base image?", isPresented: $confirmingLinuxDelete,
titleVisibility: .visible
) {
Button("Delete & rebuild", role: .destructive) {
Task { await deleteLinuxBaseImage() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Removes the installed Linux base so the next build rebuilds it from scratch. "
+ "Running sessions are unaffected.")
}
}
/// The Linux base "Ready — …" status line (or an "incomplete, rebuild" note), mirroring
/// ``baseStatusView`` for macOS.
@ViewBuilder private func linuxBaseStatusView(_ status: MacVMBaseStatus) -> some View {
if status.provisioned {
Label("Ready — desktop, builds, and computer use.", systemImage: "checkmark.seal.fill")
.font(.caption).foregroundStyle(.green)
} else {
Label("Installed — desktop/toolchain provisioning incomplete; rebuild.",
systemImage: "checkmark.circle").font(.caption)
}
}
/// A friendly distro label for the installed Linux base (e.g. "Ubuntu 26.04 LTS"), the Linux
/// counterpart to the macOS "Installed guest macOS" version line.
private func linuxDistroLabel(_ status: MacVMBaseStatus) -> String {
if let v = status.osVersion, !v.isEmpty { return "Ubuntu \(v) LTS" }
return "Ubuntu LTS"
}
// MARK: - Shared sections (apply across both guests)
@ViewBuilder private var computerUseSection: some View {
Section("Computer use") {
// The two kinds of computer use, applied to whichever guests are enabled. Semantic
// understanding builds on kernel-level use, so it only appears once that's on.
Toggle("Kernel-level use", isOn: kernelUseBothBinding)
.disabled(!anyServiceEnabled || !supported)
if kernelUseBoth {
Toggle("Semantic understanding", isOn: semanticBothBinding)
.disabled(!anyServiceEnabled || !supported)
LearnMoreLink(url: SupportURL.computerUse)
}
Picker("Default computer use VM type", selection: $defaultComputerUseVMType) {
ForEach(ComputerUseVMType.allCases, id: \.rawValue) { type in
Text(type.label).tag(type.rawValue)
}
}
.disabled(!supported)
Toggle("Let agents create lightweight containers for simpler work",
isOn: $agentContainersEnabled)
.disabled(!containerServiceEnabled)
Toggle("Automatically open VM monitors", isOn: $autoOpenVMMonitors)
.disabled(!anyServiceEnabled || !supported)
Text("Shows the open chat's VM screen in the right column while it has a VM running, "
+ "and closes it again when you switch to a chat without one.")
.settingsCaption()
Toggle("Float VM monitors in Picture in Picture", isOn: $pipVMMonitors)
.disabled(!anyServiceEnabled || !supported)
.onChange(of: pipVMMonitors) { _, on in
if !on { VMMonitorPiPController.shared.hide() } // retract immediately
}
Text("Keeps the open chat's VM screen in a small window that floats above all other "
+ "windows — even other apps — while it has a VM running. Look-only: it never "
+ "captures your mouse or keyboard.")
.settingsCaption()
}
}
/// Design-only preview of the Linux base "Packages…" panel: many ways to customize what's baked
/// into the base image, all disabled until the backing is implemented.
private struct LinuxPackagesSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var aptPackages = ""
@State private var snapPackages = ""
@State private var pipPackages = ""
@State private var repositories = ""
@State private var provisioningScript = ""
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Linux base packages").font(.headline)
Text("Customize what's baked into the Linux base image.")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("Coming soon")
.font(.caption.weight(.medium))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Capsule().fill(.quaternary))
}
.padding(16)
Divider()
Form {
Section("System packages") {
TextField("APT packages", text: $aptPackages, prompt: Text("git, build-essential, jq"))
TextField("Snap packages", text: $snapPackages, prompt: Text("code, chromium"))
}
Section("Language packages") {
TextField("pip packages", text: $pipPackages, prompt: Text("numpy, requests"))
}
Section("Repositories") {
TextField("APT repositories / PPAs", text: $repositories,
prompt: Text("ppa:deadsnakes/ppa"))
Button {
} label: {
Label("Import package list…", systemImage: "square.and.arrow.down")
}
}
Section("Custom provisioning") {
TextEditor(text: $provisioningScript)
.frame(minHeight: 80)
.font(.system(.caption, design: .monospaced))
Text("Runs during the base build, after packages are installed.")
.settingsCaption()
}
}
.formStyle(.grouped)
.disabled(true) // design-only: nothing here is wired up yet
Divider()
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.defaultAction)
}
.padding(16)
}
.frame(width: 460, height: 560)
}
}
@ViewBuilder private var resourcesSection: some View {
Section("Per-session VM resources") {
Stepper("CPUs: \(vmCPUs)", value: $vmCPUs, in: 1...32)
Stepper("Memory: \(vmMemoryGiB) GB", value: $vmMemoryGiB, in: 2...128)
Stepper("Max concurrent VMs: \(maxConcurrent)", value: $maxConcurrent, in: 1...8)
Text("Applies to macOS and Linux guests alike. macOS caps how many macOS guests run at "
+ "once (2 on recent releases); extra agents queue for a VM rather than failing.")
.settingsCaption()
}
.disabled((!serviceEnabled && !linuxServiceEnabled) || !supported)
}
@ViewBuilder private var runningNowSection: some View {
if !runningVMs.isEmpty {
Section("Running now") {
ForEach(runningVMs, id: \.name) { vm in
// Prefer the NAT IP when the guest has one, but never let a missing IP read as
// "booting…" — the vsock control plane leaves a fully-ready guest with no lease, so
// fall back to the real readiness state instead of the (absent) IP.
LabeledContent(vm.name, value: vm.ipAddress ?? (vm.booting ? "booting…" : "running"))
}
}
} else {
Section("Running now") {
Text("No virtual machines are running right now.")
.settingsCaption()
}
}
}
// MARK: - Included apps (baked into the base image's /Applications)
/// One row in the "Included apps" list: the app's Finder icon, its name, its full path, and a
/// remove control.
@ViewBuilder
private func includedAppRow(_ path: String) -> some View {
HStack(spacing: 10) {
Image(nsImage: NSWorkspace.shared.icon(forFile: path))
.resizable()
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 1) {
Text(Self.appDisplayName(path)).font(.callout)
Text(path)
.font(.caption2).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
}
Spacer()
Button {
removeApp(path)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.borderless)
.help("Remove")
}
}
/// The user-facing app name (bundle name minus the `.app` extension).
private static func appDisplayName(_ path: String) -> String {
((path as NSString).lastPathComponent as NSString).deletingPathExtension
}
/// Add dragged/browsed URLs, keeping only existing `.app` bundles and de-duplicating against the
/// current list. Returns whether anything was accepted (also the `.dropDestination` result).
@discardableResult
private func addApps(_ urls: [URL]) -> Bool {
let fm = FileManager.default
let apps = urls.filter {
$0.pathExtension.lowercased() == "app" && fm.fileExists(atPath: $0.path)
}
guard !apps.isEmpty else { return false }
var updated = bundledApps
for url in apps where !updated.contains(url.path) {
updated.append(url.path)
}
guard updated != bundledApps else { return false }
bundledApps = updated
MacVMSettings.setBundledAppPaths(bundledApps)
return true
}
/// Drop an app from the include list (does not touch the source bundle on disk).
private func removeApp(_ path: String) {
bundledApps.removeAll { $0 == path }
MacVMSettings.setBundledAppPaths(bundledApps)
}
/// Copy the configured apps into every running VM over vsock (no rebuild), surfacing the outcome.
private func pushAppsToRunningVMs() async {
pushingApps = true
appPushStatus = nil
defer { pushingApps = false }
appPushStatus = await store.pushIncludedAppsToRunningMacVMs()
}
/// Inject the configured apps into the existing base image (boot → copy → power off), so new VMs
/// include them without a full rebuild. Refreshes base status afterward.
private func addAppsToBase() async {
addingToBase = true
// Live status while the (multi-minute) boot → copy → power-off runs: the base image also
// appears in the sidebar's Virtual Machines section while this is in flight.
baseAddStatus = "Booting the base image to install apps — this can take a few minutes. "
+ "It appears in the sidebar under Virtual Machines while it runs."
defer { addingToBase = false }
baseAddStatus = await store.addIncludedAppsToMacVMBase()
baseStatus = await store.macVMBaseStatus()
}
/// Install the selected common packages into every running VM over vsock (no rebuild).
private func pushPackagesToRunningVMs() async {
pushingPackages = true
packagePushStatus = nil
defer { pushingPackages = false }
packagePushStatus = await store.pushSelectedPackagesToRunningMacVMs()
}
/// Install the selected common packages into the existing base image (boot → install → power off),
/// so new VMs include them without a full rebuild. Refreshes base status afterward.
private func addPackagesToBase() async {
addingPackagesToBase = true
packageAddStatus = "Booting the base image to install packages — this can take a few minutes. "
+ "It appears in the sidebar under Virtual Machines while it runs."
defer { addingPackagesToBase = false }
packageAddStatus = await store.addSelectedPackagesToMacVMBase()
baseStatus = await store.macVMBaseStatus()
}
/// Browse for one or more `.app` bundles to include, defaulting to `/Applications`.
private func chooseApps() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.allowedContentTypes = [.application]
panel.treatsFilePackagesAsDirectories = false
panel.directoryURL = URL(fileURLWithPath: "/Applications")
panel.message = "Choose “.app” bundles to include in the macOS VM base image"
panel.prompt = "Add"
if panel.runModal() == .OK { addApps(panel.urls) }
}
// MARK: - Guest-image (.ipsw) catalog
/// One selectable restore image in the hardcoded catalog. `url` is the remote `.ipsw`; an empty
/// `url` is a placeholder (image not published yet) that falls back to "latest supported".
private struct IPSWChoice: Identifiable, Hashable {
let id: String
let label: String
let url: String
}
/// Hardcoded list of guest macOS restore images offered in the picker. Selecting one writes its
/// `url` into the `restoreImageURL` setting (empty → clears it, i.e. latest supported).
private static let ipswCatalog: [IPSWChoice] = [
IPSWChoice(
id: "macos27-devbeta",
label: "macOS 27 Developer Beta",
url: "https://updates.cdn-apple.com/2026SummerSeed/fullrestores/140-36017/"
+ "0F8D2136-7924-4F3E-A9AA-99722A9EFB39/UniversalMac_27.0_26A5378j_Restore.ipsw"),
IPSWChoice(id: "macos27-publicbeta", label: "macOS 27 Public Beta", url: ""),
]
/// Look up a catalog choice by its picker id.
private static func ipswChoice(for id: String) -> IPSWChoice? {
ipswCatalog.first { $0.id == id }
}
/// Apply a picker selection: point the restore-image URL at the chosen catalog image (empty for a
/// not-yet-published placeholder, which the engine treats as "latest supported").
private func applyIPSWChoice(_ id: String) {
restoreImageURL = Self.ipswChoice(for: id)?.url ?? ""
}
/// Resolve the picker selection on appearance: an unknown/legacy value falls back to the default
/// image and the chosen image's URL is kept in sync. Also clears any stale manual restore-source
/// overrides left by the removed "Advanced" panel so they can't silently override the picker.
private func reconcileBaseImageChoice() {
UserDefaults.standard.removeObject(forKey: MacVMSettings.basePrebuiltPathKey)
UserDefaults.standard.removeObject(forKey: MacVMSettings.restoreImagePathKey)
if Self.ipswChoice(for: restoreImageChoice) == nil { restoreImageChoice = "macos27-devbeta" }
applyIPSWChoice(restoreImageChoice)
}
/// Post-build status: exec-ready vs. fully computer-use-ready. Semantic AX ships in the base
/// image and its grants are written automatically during the build, so once the base is
/// provisioned everything — builds, computer use, and semantic AX — is ready.
@ViewBuilder
private func baseStatusView(_ status: MacVMBaseStatus) -> some View {
// Computer use works host-side on any installed base; a fully provisioned base adds the toolchain.
if status.provisioned {
Label("Ready — builds + computer use + semantic AX.", systemImage: "checkmark.seal.fill")
.font(.caption).foregroundStyle(.green)
} else {
Label("Computer use ready. Toolchain provisioning incomplete — rebuild.",
systemImage: "checkmark.circle").font(.caption)
}
}
private func runBaseBuild() async {
building = true
buildError = nil
defer { building = false }
do {
try await store.buildMacVMBaseImage(localRestoreImagePath: nil)
} catch {
buildError = "\(error)"
}
}
/// Kick off the Linux base build. `linuxBuilding` routes the shared progress bar to the Linux
/// section (only one base builds at a time).
private func runLinuxBaseBuild() async {
linuxBuilding = true
linuxBuildError = nil
defer { linuxBuilding = false }
do {
try await store.buildLinuxVMBaseImage()
} catch {
linuxBuildError = "\(error)"
}
linuxBaseStatus = await store.linuxVMBaseStatus()
}
/// Delete the installed Linux base so the next build rebuilds from scratch.
private func deleteLinuxBaseImage() async {
linuxDeleting = true
linuxBuildError = nil
defer { linuxDeleting = false }
do {
try await store.deleteLinuxVMBaseImage()
linuxBaseStatus = await store.linuxVMBaseStatus()
} catch {
linuxBuildError = "\(error)"
}
}
/// Delete the installed base (and cached restore image) so the next build reinstalls macOS from
/// scratch. Refreshes status immediately so the panel flips back to "Build base image".
private func deleteBaseImage(includingRestoreImages: Bool) async {
deleting = true
buildError = nil
defer { deleting = false }
do {
try await store.deleteMacVMBaseImage(includingRestoreImages: includingRestoreImages)
baseStatus = await store.macVMBaseStatus()
baseOSVersion = await store.macVMBaseOSVersion()
} catch {
buildError = "\(error)"
}
}
/// Poll base-build progress + the live VM list while the tab is open.
private func poll() async {
while !Task.isCancelled {
let progress = await store.macVMBaseProgress()
recordBuildPhase(progress)
baseProgress = progress
// Re-derive which section the shared build belongs to from engine state, not just the
// view-local flags: those are set optimistically on click but reset to `false` whenever the
// user navigates out of and back into Settings (the tab's `@State` is torn down), which used
// to make an in-flight build's progress vanish — or worse, a live Linux build re-render under
// the macOS section. The engine flag outlives the view, so a returning tab re-adopts it.
let buildingGuest = await store.macVMBaseBuildGuest()
building = buildingGuest == .macOS
linuxBuilding = buildingGuest == .linux
// The build ended (surface detached, monitor auto-hidden) — reset the toggle to match.
if progress == nil, observingBaseVM {
observingBaseVM = false
await store.setMacVMBaseObserver(visible: false)
}
runningVMs = await store.runningMacVMs()
baseOSVersion = await store.macVMBaseOSVersion()
baseStatus = await store.macVMBaseStatus()
linuxBaseStatus = await store.linuxVMBaseStatus()
try? await Task.sleep(for: .seconds(2))
}
}
/// Grow (or reset) the observed-phase checklist. Phases are reported one at a time and advance
/// monotonically, so appending each newly-seen phase preserves build order; a `nil` progress
/// (no build running) clears the list for the next run.
private func recordBuildPhase(_ progress: MacVMBaseProgress?) {
guard let progress else {
if !seenBuildPhases.isEmpty { seenBuildPhases = [] }
return
}
if !seenBuildPhases.contains(progress.phase) {
seenBuildPhases.append(progress.phase)
}
}
/// One row in the build checklist: a green check for a completed stage, or the stage label plus
/// an animated linear bar (indeterminate, or determinate when the phase reports a fraction) for
/// the stage in progress.
@ViewBuilder
private func buildStageRow(
_ phase: MacVMBaseProgress.Phase, os: GuestOS, active: Bool, fraction: Double?,
detail: String? = nil
) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: active ? "circle.fill" : "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(active ? AnyShapeStyle(.tint) : AnyShapeStyle(.green))
.frame(width: 14)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(MacVMBaseProgress(phase: phase).label(for: os))
.font(.caption)
.fontWeight(active ? .semibold : .regular)
.foregroundStyle(active ? .primary : .secondary)
if active {
if let fraction {
ProgressView(value: fraction).progressViewStyle(.linear)
} else {
ProgressView().progressViewStyle(.linear)
}
if let detail, !detail.isEmpty {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
/// 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))
}
}
}
/// 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 Nucleics secret store (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
/// Optional overrides for the git identity a Nucleic-managed key commits as. Empty name → the
/// default "Nucleic"; empty email → keep the repo's own `user.email` (so GitHub can verify the
/// commit against the account whose signing key the managed key is).
@AppStorage(GitHubCredentialSettings.managedCommitNameKey) private var commitName = ""
@AppStorage(GitHubCredentialSettings.managedCommitEmailKey) private var commitEmail = ""
/// 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)
}
switch mode {
case .none:
EmptyView()
case .token:
tokenControls
case .ssh:
sshKeyEntry
sshOptions
case .managed:
managedKeyEntry
commitIdentityEntry
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)
} else {
Toggle("Make available in sandbox", isOn: $availableInSandbox)
.disabled(mode == .none)
}
}
.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()
}
/// Save/clear UI for the stored PAT. 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() -> 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)
}
}
// 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)
}
}
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)
}
}
}
// MARK: Managed commit identity (author/committer overrides)
/// Overrides for the git identity a Nucleic-managed key commits as. By default a managed-key
/// commit is authored as "Nucleic" but keeps the repo's own `user.email`, so GitHub can verify it
/// against the account the managed key belongs to. These fields let the user pin a specific
/// name/email instead; blank means "use the default".
@ViewBuilder private var commitIdentityEntry: some View {
LabeledContent("Commit name") {
TextField("Nucleic", text: $commitName)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
}
LabeledContent("Commit email") {
TextField("your git config email", text: $commitEmail)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
}
Text("Commits Nucleic makes with the managed key are authored as this name; a blank email "
+ "keeps the repository's own git email so GitHub can verify them against the account the "
+ "key belongs to.")
.font(.caption)
.foregroundStyle(.secondary)
}
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)
Toggle("Force gh to use SSH", isOn: $ghForceSSH)
Toggle("Restrict git to SSH for GitHub", isOn: $gitForceSSH)
Text("API token for gh (optional)")
.font(.callout.weight(.medium))
tokenEntry()
}
// 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(ModelCatalog.defaultAutoKey) private var defaultAuto = false
@AppStorage(ModelCatalog.defaultAutoShipKey) private var defaultAutoShip = 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.codexControlAuthModeKey)
private var codexAuthMode = ControlAuthMode.oauth.rawValue
@AppStorage(ContainerServiceSettings.splitControlContainersByBackendKey)
private var splitContainersByBackend = false
@AppStorage(ContainerServiceSettings.memoryManagementKey)
private var memoryManagement = MemoryManagementLevel.conservative.rawValue
@AppStorage(ContainerServiceSettings.commandTracingEnabledKey)
private var commandTracing = false
@AppStorage(ContainerServiceSettings.autoCheckImageUpdatesKey)
private var autoCheckImageUpdates = true
/// 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
/// Codex API-key draft + saved-state, siblings of the Claude ones above.
@State private var codexAPIKeyDraft = ""
@State private var codexKeySaved = CodexControlAPIKeyStore.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
/// True while an in-place "Check for updates" (agent-CLI refresh) is in flight.
@State private var checkingUpdates = false
/// Result of the last "Check for updates" run, presented in a confirmation alert. `nil` hides it.
@State private var updateResults: [AgentCLIUpdateResult]?
/// 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") {
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)
Toggle("Enable Autoship on new chats by default", isOn: $defaultAutoShip)
.onChange(of: defaultAutoShip) { _, enabled in
if enabled { defaultAuto = true } // shipping implies autonomous
store.defaultAuto = defaultAuto
store.defaultAutoShip = enabled
}
LearnMoreLink("What is Nucleic Control?", url: SupportURL.nucleicControl)
}
Section {
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)
} 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)
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)
}
}
}
Picker("Authenticate Codex with", selection: $codexAuthMode) {
Text("ChatGPT subscription (OAuth)").tag(ControlAuthMode.oauth.rawValue)
Text("OpenAI API key").tag(ControlAuthMode.apiKey.rawValue)
}
.disabled(!containerServiceEnabled)
if codexAuthMode == ControlAuthMode.apiKey.rawValue {
SecureField("sk-…", text: $codexAPIKeyDraft)
.disabled(!containerServiceEnabled)
HStack(spacing: 10) {
Button("Save Key") {
if CodexControlAPIKeyStore.save(codexAPIKeyDraft) { codexKeySaved = CodexControlAPIKeyStore.hasKey }
codexAPIKeyDraft = ""
}
.disabled(!containerServiceEnabled
|| codexAPIKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if codexKeySaved {
Button("Clear", role: .destructive) {
CodexControlAPIKeyStore.clear()
codexKeySaved = false
}
Label("Key saved", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green).font(.caption)
}
}
} else {
HStack(spacing: 10) {
Button(store.codexLoginInProgress ? "Signing in\u{2026}" : "Sign in to Codex") {
Task { await store.loginCodex() }
}
.disabled(!containerServiceEnabled || store.codexLoginInProgress)
if store.codexLoginInProgress { ProgressView().controlSize(.small) }
}
Text("Nucleic signs you in through your browser and writes `~/.codex/auth.json` \u{2014} no separate `codex login` terminal step. The login is kept in sync after each turn and shared to your other devices over Covalence.")
.font(.caption).foregroundStyle(.secondary)
}
}
Section("Nucleic Control container") {
LabeledContent("CPUs", value: "Auto")
LabeledContent("Memory", value: "Auto")
Picker("Memory management", selection: $memoryManagement) {
ForEach(MemoryManagementLevel.allCases, id: \.rawValue) { level in
Text(level.label).tag(level.rawValue)
}
}
.disabled(!containerServiceEnabled)
Toggle("Agent Lifecycle Protection", isOn: $splitContainersByBackend)
.disabled(!containerServiceEnabled)
Toggle("Trace shell commands", isOn: $commandTracing)
.disabled(!containerServiceEnabled)
Toggle("Automatically update the sandbox image", isOn: $autoCheckImageUpdates)
.disabled(!containerServiceEnabled)
.onChange(of: autoCheckImageUpdates) { store.startImageUpdateChecksIfNeeded() }
.help("Periodically check the registry for a newer sandbox base image (a small "
+ "digest check, no download) and surface it in Maintenance. When on, the next "
+ "sandbox that starts also pulls a newer image automatically instead of "
+ "reusing the cached one — no waiting for an app update. A container already "
+ "running keeps its current image until it's re-created.")
}
Section("Maintenance") {
Button {
checkingUpdates = true
Task {
// Check the registry for a newer base image (cheap digest HEAD) and refresh
// the in-place CLIs together — one "check for updates" covers both assets.
async let image = store.checkForImageUpdate(userInitiated: true)
async let clis = store.updateControlAgentCLIs()
let (_, results) = await (image, clis)
checkingUpdates = false
updateResults = results
}
} label: {
HStack(spacing: 6) {
if checkingUpdates { ProgressView().controlSize(.small) }
Text(checkingUpdates ? "Checking…" : "Check for updates")
}
}
.disabled(!containerServiceEnabled || checkingUpdates)
.help("Check the registry for a newer sandbox base image and update the Codex and "
+ "Claude Code CLIs in the running container to their latest versions — instant "
+ "access to new models. Applying a base-image update uses Force re-creation.")
imageUpdateStatusRow
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)
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)
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)
}
}
.formStyle(.grouped)
.task {
// Refresh the base-image update status when the tab opens (cheap digest check; no-op when
// the service is off), so the Maintenance surface reflects the registry without waiting
// for the periodic background loop.
await store.checkForImageUpdate()
}
.alert("Check for updates", isPresented: Binding(
get: { updateResults != nil },
set: { if !$0 { updateResults = nil } }
), presenting: updateResults) { _ in
Button("OK", role: .cancel) { updateResults = nil }
} message: { results in
Text(Self.imageStatusLine(store.imageUpdateStatus) + Self.updateSummary(results))
}
}
/// One-line base-image verdict prepended to the "Check for updates" alert.
private static func imageStatusLine(_ status: ImageUpdateStatus) -> String {
switch status {
case .updateAvailable:
return ContainerServiceSettings.autoCheckImageUpdates
? "Base image: a newer version is available — the next sandbox to start picks it up "
+ "automatically, or apply it now with Update now / Force re-creation.\n\n"
: "Base image: a newer version is available — apply it with Update now / Force re-creation.\n\n"
case .upToDate:
return "Base image: up to date.\n\n"
case .notInstalled:
return "Base image: not downloaded yet.\n\n"
case .unknown:
return "Base image: couldn't check (offline or registry unreachable).\n\n"
case .idle, .checking:
return ""
}
}
/// A row reflecting the sandbox base-image asset auto-update check (`store.imageUpdateStatus`).
/// Invites the user to apply an available update via Force re-creation; otherwise a subtle status
/// line (or nothing before the first check / when the registry couldn't be reached).
@ViewBuilder private var imageUpdateStatusRow: some View {
switch store.imageUpdateStatus {
case .updateAvailable:
HStack(spacing: 8) {
Label("A newer sandbox image is available", systemImage: "shippingbox")
.foregroundStyle(.orange)
Spacer()
Button("Update now") {
recreating = true
Task {
await store.applyImageUpdate()
recreating = false
}
}
.disabled(recreating || restarting)
}
case .checking:
Label("Checking for image updates…", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(.secondary).font(.caption)
case .upToDate:
Label("Sandbox image is up to date", systemImage: "checkmark.circle")
.foregroundStyle(.secondary).font(.caption)
case .notInstalled:
Label("Sandbox image not downloaded yet", systemImage: "arrow.down.circle")
.foregroundStyle(.secondary).font(.caption)
case .idle, .unknown:
EmptyView()
}
}
/// Human-readable summary of a "Check for updates" run for the confirmation alert.
private static func updateSummary(_ results: [AgentCLIUpdateResult]) -> String {
guard !results.isEmpty else {
return "No running control container to update. Start a control chat (or use Force "
+ "re-creation to rebuild the base image), then try again."
}
return results.map { r in
let head = r.success ? "✓ \(r.container)" : "✗ \(r.container)"
let detail = r.detail.isEmpty ? "" : "\n\(r.detail)"
return head + detail
}.joined(separator: "\n\n")
}
}
/// 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
@AppStorage(EphemeralChatPolicy.savedByDefaultKey) private var scratchSavedByDefault = true
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)
Toggle("Show lock events", isOn: $showLockEvents)
}
Section("Composer") {
Picker("Send with", selection: $submitKeyRaw) {
ForEach(SubmitKeyMode.allCases) { Text($0.label).tag($0.rawValue) }
}
}
Section("Archiving") {
Picker("Auto-archive completed chats", selection: $autoArchiveRaw) {
ForEach(AutoArchivePolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
Picker("Delete archived chat worktrees", selection: $worktreeCleanupRaw) {
ForEach(ArchivedWorktreeCleanupPolicy.allCases) { Text($0.label).tag($0.rawValue) }
}
}
Section("Scratch chats") {
Toggle("Keep scratch chats by default", isOn: $scratchSavedByDefault)
Text(scratchSavedByDefault
? "New out-of-project (Scratch) chats are kept when you close their window; switch a chat to Ephemeral in its window to discard it on close."
: "New out-of-project (Scratch) chats are discarded — along with their scratch workspace — when you close their window; switch a chat to Saved in its window to keep it.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.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), 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.defaultSupervisorModelKey) private var defaultSupervisorModel = ModelCatalog.fallbackSupervisorModel
@AppStorage(ModelCatalog.defaultOrchestraWorkerModelKey) private var defaultOrchestraWorkerModel = ModelCatalog.fallbackOrchestraWorkerModel
@AppStorage(ModelCatalog.defaultOrchestraMaxWorkersKey) private var defaultOrchestraMaxWorkers = ModelCatalog.fallbackOrchestraMaxWorkers
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
@AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
@AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
@AppStorage(ExternalAgentIntegration.mcp.defaultsKey)
private var externalMCPModels = ExternalAgentIntegration.mcp.defaultModelSelection
@AppStorage(ExternalAgentIntegration.skills.defaultsKey)
private var externalSkillsModels = ExternalAgentIntegration.skills.defaultModelSelection
@AppStorage(ExternalAgentIntegration.plugins.defaultsKey)
private var externalPluginModels = ExternalAgentIntegration.plugins.defaultModelSelection
@AppStorage(ACPAgent.genericExecutableDefaultsKey) private var genericACPExecutable = ""
@AppStorage(ACPAgent.genericArgumentsDefaultsKey) private var genericACPArguments = ""
/// 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] = []
/// Three-state status dot: green = connected, orange = installed-but-unauthenticated, red = not
/// installed. The middle state is the one that pairs with the "Log in" button.
private static func backendStatusColor(_ p: ProviderStatus) -> Color {
if p.isConnected { return .green }
if p.installed { return .orange }
return .red
}
/// The three-state label shown at the trailing edge of a backend row.
private static func backendStatusText(_ p: ProviderStatus) -> String {
if p.isConnected { return "Connected" }
if p.installed { return "Unauthenticated" }
return "Not Connected"
}
/// Providers Nucleic can sign in for via its mediated OAuth — Claude and Codex. Others own their
/// own auth, so no in-app button is offered for them.
private static func canLogIn(_ backend: BackendID) -> Bool {
switch backend {
case .claudeCode, .codex, .codexExec: return true
default: return false
}
}
var body: some View {
Form {
Section("New chats") {
Picker("Default model", selection: $defaultModel) {
ForEach(ModelCatalog.models, id: \.self) { sku in
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
Text("\(ModelCatalog.displayName(sku))\(badge)")
.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)
}
Section("Orchestra") {
Picker("Supervisor model", selection: $defaultSupervisorModel) {
ForEach(ModelCatalog.models, id: \.self) { sku in
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
Text("\(ModelCatalog.displayName(sku))\(badge)")
.tag(sku)
}
}
Picker("Worker model", selection: $defaultOrchestraWorkerModel) {
ForEach(ModelCatalog.models, id: \.self) { sku in
let badge = ModelCatalog.contextBadge(for: sku).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")
Text("\(ModelCatalog.displayName(sku))\(badge)")
.tag(sku)
}
}
Picker("Max concurrent workers", selection: $defaultOrchestraMaxWorkers) {
ForEach(ModelCatalog.orchestraMaxWorkerChoices, id: \.self) { count in
Text(count <= 0 ? "Unlimited" : "\(count)").tag(count)
}
}
.help("How many worker subagents the supervisor may run at once. Extra spawns wait for a free slot. The fan-out multiplies token usage, so raise this deliberately.")
}
Section("External integrations") {
integrationModelsRow("MCP servers", selection: $externalMCPModels)
integrationModelsRow("Skills", selection: $externalSkillsModels)
integrationModelsRow("Plugins", selection: $externalPluginModels)
Text(
"Use provider-configured integrations from your agent homes. Choose one or "
+ "more models for each category. Skills retain their existing all-model "
+ "behavior; MCP servers and plugins are explicit opt-ins.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Custom ACP agent") {
TextField("Executable", text: $genericACPExecutable, prompt: Text("acp-agent"))
.textFieldStyle(.roundedBorder)
TextField("Arguments", text: $genericACPArguments, prompt: Text("acp"))
.textFieldStyle(.roundedBorder)
Text("Configure any command that speaks Agent Client Protocol over stdio. Arguments are split without invoking a shell; quotes and backslash escaping are supported.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Backend") {
ForEach(providers) { provider in
HStack(spacing: 8) {
Circle()
.fill(Self.backendStatusColor(provider))
.frame(width: 8, height: 8)
Text(provider.name)
Spacer()
// Installed but not signed in, for a provider Nucleic can drive → a one-click
// browser sign-in (Claude/Codex OAuth). Re-probe after so the row updates.
if provider.installed, !provider.authenticated,
Self.canLogIn(provider.backend) {
Button("Log in") {
Task {
await store.login(forBackend: provider.backend)
providers = await ProviderAvailability.probeAll()
}
}
.controlSize(.small)
}
Text(Self.backendStatusText(provider))
.foregroundStyle(.secondary)
}
.help(provider.detail)
}
}
.task(id: genericACPExecutable + "|" + genericACPArguments) {
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)
ForEach(StatusProvider.allCases) { provider in
Toggle("Monitor \(provider.displayName)", isOn: providerBinding(provider))
}
}
}
.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: defaultSupervisorModel) { _, _ in pushDefaults() }
.onChange(of: defaultOrchestraWorkerModel) { _, _ in pushDefaults() }
.onChange(of: defaultOrchestraMaxWorkers) { _, _ in pushDefaults() }
.onChange(of: defaultAuto) { _, _ in pushDefaults() }
}
@ViewBuilder
private func integrationModelsRow(_ title: String, selection: Binding<String>) -> some View {
HStack {
Text(title)
Spacer()
Menu(scopeLabel(selection.wrappedValue)) {
Button("All models") { selection.wrappedValue = "*" }
Button("No models") {
selection.wrappedValue = ExternalAgentIntegrationSettings.encodedModels([])
}
Divider()
ForEach(ModelCatalog.models, id: \.self) { model in
Button {
toggleIntegrationModel(model, selection: selection)
} label: {
if scopeContains(model, raw: selection.wrappedValue) {
Label(ModelCatalog.displayName(model), systemImage: "checkmark")
} else {
Text(ModelCatalog.displayName(model))
}
}
}
}
.fixedSize()
}
}
private func scopeContains(_ model: String, raw: String) -> Bool {
let models = ExternalAgentIntegrationSettings.models(from: raw)
return models.contains("*") || models.contains(model)
}
private func scopeLabel(_ raw: String) -> String {
let models = ExternalAgentIntegrationSettings.models(from: raw)
if models.contains("*") { return "All models" }
if models.isEmpty { return "No models" }
if models.count == 1, let model = models.first {
return ModelCatalog.displayName(model)
}
return "\(models.count) models"
}
private func toggleIntegrationModel(_ model: String, selection: Binding<String>) {
var models = ExternalAgentIntegrationSettings.models(from: selection.wrappedValue)
if models.contains("*") { models = Set(ModelCatalog.models) }
if models.contains(model) {
models.remove(model)
} else {
models.insert(model)
}
selection.wrappedValue = models == Set(ModelCatalog.models)
? "*" : ExternalAgentIntegrationSettings.encodedModels(models)
}
private func pushDefaults() {
store.defaultModel = defaultModel
store.defaultEffort = defaultEffort
store.defaultSupervisorModel = defaultSupervisorModel
store.defaultOrchestraWorkerModel = defaultOrchestraWorkerModel
store.defaultOrchestraMaxConcurrentWorkers = defaultOrchestraMaxWorkers
store.defaultAuto = defaultAuto
}
/// 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)
})
}
}
/// Usage: the subscription-limit gauges from the home dashboard, gathered into Settings so
/// they're checkable without leaving the panel. Both are the same cards the home view shows:
/// `QuotaCard` reads `AppStore.subscriptionUsage` (Claude) and `CodexUsageCard` reads
/// `AppStore.codexUsage` (Codex), each falling back to its own placeholder before data lands.
/// Not a `Form`: the pane shows the dashboard cards verbatim so the two views stay identical.
private struct UsageSettingsTab: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("The same account-wide subscription limits shown on the home dashboard.")
.settingsCaption()
QuotaCard(title: "Claude")
CodexUsageCard()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
}
}
/// 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)
}
Section("Tool call summaries") {
Toggle("Summarize tool calls with AI", isOn: $summarizeToolCalls)
if summarizeToolCalls {
Picker("AI summarizes", selection: $toolSummaryScopeRaw) {
Text("Bash commands only").tag(ToolSummaryScope.bashOnly.rawValue)
Text("All tool calls").tag(ToolSummaryScope.all.rawValue)
}
}
}
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
}
}
/// The "Covalence" tab: the global Covalence switch plus its mesh, transport, relay, and runner
/// settings.
private struct RemoteSettingsTab: View {
@Environment(AppStore.self) private var store
@AppStorage(AppStore.remoteEnabledDefaultsKey) private var covalenceEnabled = false
// Mesh participation knobs — moved here from the Agents tab so every cloud/mesh setting
// lives on the Covalence tab. Both only take effect while the mesh (Covalence) is on, so they
// ride the same `covalenceEnabled` gate as the rest of the pane.
@AppStorage(MeshDispatchSettings.acceptsKey) private var acceptsMeshDispatch = true
@AppStorage(MeshMessagingSettings.enabledKey) private var meshMessagingEnabled = false
@AppStorage(MeshDebugLog.enabledKey) private var meshDebugLogging = false
@State private var confirmReset = false
@State private var exportingLogs = false
var body: some View {
Form {
Section {
Toggle("Covalence", isOn: Binding(
get: { covalenceEnabled },
set: { enabled in
covalenceEnabled = enabled
Task {
enabled
? await store.startSyncServer()
: await store.stopSyncServer()
}
}
))
}
Group {
Section("Covalence dispatch") {
Toggle("Accept Covalence-dispatched chats", isOn: $acceptsMeshDispatch)
Text("Lets the composer's \"Covalence\" destination on your other devices "
+ "auto-route new chats here when this host is the best fit — and lets "
+ "idle Covalence chats move here between turns when this host frees up.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Agent messaging across the mesh", isOn: $meshMessagingEnabled)
Text("Lets agents send short messages to sessions on this Mac and your other "
+ "meshed Macs/runners (`nucleic_send_message`). Deliveries land as visible "
+ "user turns in the target chat. Applies to chats started after toggling.")
.font(.caption)
.foregroundStyle(.secondary)
}
.onChange(of: acceptsMeshDispatch) {
// The presence card carries this flag — re-announce it mesh-wide.
store.publishRunnerPresence()
}
RemoteAccessSection()
}
.disabled(!covalenceEnabled)
.opacity(covalenceEnabled ? 1 : 0.5)
// Left outside the `covalenceEnabled` gate on purpose — this is the escape hatch for
// when the mesh is stuck or Covalence won't come back up on its own, so it needs to
// work whether or not the toggle above currently reads "on". The viewer sits with
// it for the same reason: diagnosing a stuck mesh is exactly when you need it.
Section {
Button {
CovalenceMeshViewerWindowController.shared.open(store: store)
} label: {
Label("Open Covalence Mesh Viewer", systemImage: "waveform.path.ecg")
}
Text("Live mesh state, host health, and an event log of dispatch, rebalance, "
+ "and transfer decisions — for observing Covalence and diagnosing issues.")
.font(.caption).foregroundStyle(.secondary)
Toggle("Mesh debug logging", isOn: $meshDebugLogging)
Text("Writes every mesh event — relay dials and failures, peer connects, "
+ "dispatch and transfer decisions — to a rolling log file that survives "
+ "relaunches, so problems can be diagnosed after the fact.")
.font(.caption).foregroundStyle(.secondary)
Button {
exportMeshLogs()
} label: {
if exportingLogs {
Label("Gathering logs…", systemImage: "square.and.arrow.up")
} else {
Label("Export Mesh & Cloud Logs…", systemImage: "square.and.arrow.up")
}
}
.disabled(exportingLogs)
Text("Saves one report with this Mac's mesh state and event trail plus the "
+ "Covalence cloud view: the relay room's connection log and the runner "
+ "pool's status.")
.font(.caption).foregroundStyle(.secondary)
Button(role: .destructive) {
confirmReset = true
} label: {
Label("Reset Covalence…", systemImage: "arrow.counterclockwise")
}
} footer: {
Text("Wipes this Mac's local mesh identity, paired devices, and relay credentials, "
+ "then turns Covalence off. For debugging, or when the mesh is stuck and nothing "
+ "else fixes it. Every paired device will need to re-pair afterward.")
}
.confirmationDialog(
"Reset Covalence?", isPresented: $confirmReset, titleVisibility: .visible
) {
Button("Reset Covalence", role: .destructive) {
Task { await store.resetCovalenceMeshConfig() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This forgets every paired phone and Mac, deletes this Mac's mesh identity "
+ "and relay credentials, and turns Covalence off. Nothing else on this Mac is "
+ "affected. You can re-enable Covalence and re-pair devices anytime.")
}
}
.formStyle(.grouped)
}
/// Gather the mesh + cloud diagnostics report (async — it fetches the relay room log
/// and pool status) and hand it to a save panel.
private func exportMeshLogs() {
exportingLogs = true
Task {
let report = await store.exportMeshDiagnostics()
exportingLogs = false
let panel = NSSavePanel()
let stamp = Date().formatted(.iso8601.year().month().day().timeSeparator(.omitted))
panel.nameFieldStringValue = "nucleic-mesh-logs-\(stamp).txt"
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
try? report.write(to: url, atomically: true, encoding: .utf8)
}
}
}
}
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)
}
}
/// Canonical links into the public support site (`nucleic.blakeslee.xyz/support`). The full
/// how-to lives on the web, so a toggle keeps a one-line hint here and defers the detail —
/// setup steps, requirements, gotchas — to the matching article via `LearnMoreLink`.
private enum SupportURL {
private static let base = "https://nucleic.blakeslee.xyz"
static let home = URL(string: "\(base)/support.html")!
static let nucleicControl = URL(string: "\(base)/support/nucleic-control.html")!
static let sandboxes = URL(string: "\(base)/support/sandboxes.html")!
static let virtualMachines = URL(string: "\(base)/support/virtual-machines.html")!
static let macVMBaseImage = URL(string: "\(base)/support/macos-vm-base-image.html")!
static let computerUse = URL(string: "\(base)/support/computer-use.html")!
}
/// A compact, caption-weight "Learn more" link to a support article. Sits directly under a
/// toggle's short hint, replacing the long inline instructions that used to clutter the UI.
private struct LearnMoreLink: View {
private let label: String
private let url: URL
init(_ label: String = "Learn more", url: URL) {
self.label = label
self.url = url
}
var body: some View {
Link(destination: url) {
HStack(spacing: 3) {
Text(label)
Image(systemName: "arrow.up.right").imageScale(.small)
}
.font(.caption)
}
.fixedSize(horizontal: false, vertical: true)
}
}