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