import SwiftUI import ServiceManagement import AppKit struct SettingsView: View { @ObservedObject private var state = AppState.shared @State private var apiKey: String = KeychainStore.shared.get("anthropic-api-key") ?? "" // Claude model — dynamic list fetched from the API, static fallback if unavailable private static let fallbackModels: [(id: String, label: String)] = [ ("claude-sonnet-4-6", "Claude Sonnet 4.6"), ("claude-sonnet-5-5", "Claude Sonnet 5.5"), ("claude-opus-5-5", "Claude Opus 5.5"), ("claude-haiku-4-5-20251001", "Claude Haiku 4.5"), ] private static let customModelTag = "__custom__" @State private var fetchedModels: [(id: String, label: String)] = [] @State private var modelChoice: String = { let m = AppState.shared.claudeModel return SettingsView.fallbackModels.contains { $0.id == m } ? m : SettingsView.customModelTag }() @State private var customModel: String = { let m = AppState.shared.claudeModel return SettingsView.fallbackModels.contains { $0.id == m } ? "" : m }() private var displayModels: [(id: String, label: String)] { fetchedModels.isEmpty ? Self.fallbackModels : fetchedModels } @State private var launchAtStartup: Bool = (SMAppService.mainApp.status == .enabled) @State private var statusMessage: String = "" @State private var showDiff: Bool = false @State private var pendingHookJSON: String = "" @State private var hookNeedsUpdate: Bool = HookServer.hooksNeedUpdate() #if !APPSTORE @State private var showStatusLineDiff: Bool = false @State private var pendingStatusLineJSON: String = "" @State private var statusLinePendingInstall: Bool = true @State private var planTogglePending: Bool = false @State private var geminiHooksInstalled: Bool = HookServer.geminiHooksInstalled() @State private var showGeminiDiff: Bool = false @State private var pendingGeminiJSON: String = "" @State private var geminiPendingInstall: Bool = true @State private var agyHooksInstalled: Bool = HookServer.agyHooksInstalled() @State private var showAgyDiff: Bool = false @State private var pendingAgyJSON: String = "" @State private var agyPendingInstall: Bool = true @State private var codexHooksInstalled: Bool = HookServer.codexHooksInstalled() @State private var showCodexDiff: Bool = false @State private var pendingCodexJSON: String = "" @State private var codexPendingInstall: Bool = true #endif // Multi-provider chat keys @State private var googleKey: String = KeychainStore.shared.get("google-api-key") ?? "" @State private var openAIKey: String = KeychainStore.shared.get("openai-api-key") ?? "" @State private var ollamaURL: String = AppState.shared.ollamaServerURL @State private var lmstudioURL: String = AppState.shared.lmstudioServerURL @State private var connectingOllama: Bool = false @State private var connectingLMStudio: Bool = false // Integration keys @State private var resendKey: String = KeychainStore.shared.get("resend-api-key") ?? "" @State private var resendFrom: String = KeychainStore.shared.get("resend-from") ?? "" @State private var n8nUrl: String = KeychainStore.shared.get("n8n-url") ?? "" @State private var n8nKey: String = KeychainStore.shared.get("n8n-api-key") ?? "" @State private var vercelToken: String = KeychainStore.shared.get("vercel-token") ?? "" @State private var githubToken: String = KeychainStore.shared.get("github-token") ?? "" @State private var stripeKey: String = KeychainStore.shared.get("stripe-api-key") ?? "" @State private var calcomKey: String = KeychainStore.shared.get("calcom-api-key") ?? "" @State private var notionKey: String = KeychainStore.shared.get("notion-api-key") ?? "" // Hotkey @State private var hotkeyFlags: UInt = AppState.shared.hotkeyFlags @State private var hotkeyCode: UInt16 = AppState.shared.hotkeyCode // Vercel project filter @State private var vercelProjects: [String] = [] @State private var loadingVercel: Bool = false // n8n workflow filter @State private var n8nWorkflows: [String] = [] @State private var loadingN8n: Bool = false // Bindings in minutes for the absence field private var absenceMinutes: Binding { Binding( get: { state.absenceInterval / 60 }, set: { state.absenceInterval = max(1, $0) * 60 } ) } // Sidebar selection persisted across sessions @AppStorage("settingsSection") private var selectedSection: String = "general" #if PHONE_LINK @AppStorage("iPhoneSyncEnabled") private var iPhoneSyncEnabled = false @AppStorage("iPhoneLiveActivityEnabled") private var iPhoneLiveActivityEnabled = false @AppStorage("iPhoneInstructionsEnabled") private var iPhoneInstructionsEnabled = false #endif private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" } // MARK: - Body var body: some View { HStack(spacing: 0) { // Sidebar — 200 pt, sidebar visual effect background ZStack(alignment: .topLeading) { SidebarBackground() VStack(alignment: .leading, spacing: 0) { // Header HStack(alignment: .center, spacing: 10) { Image(nsImage: NSApplication.shared.applicationIconImage) .resizable() .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 1) { Text("BroV") .font(.system(size: 13, weight: .semibold)) Text(appVersion) .font(.system(size: 11)) .foregroundColor(.secondary) } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 10) Divider() List(selection: Binding( get: { Optional(selectedSection) }, set: { if let v = $0 { selectedSection = v; statusMessage = "" } } )) { SettingsSidebarRow(title: "General", icon: "gearshape.fill", color: "#8E939C").tag("general") SettingsSidebarRow(title: "Active pills", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills") SettingsSidebarRow(title: "Agents", icon: "terminal.fill", color: "#3B9EFF").tag("agents") SettingsSidebarRow(title: "Chat", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat") SettingsSidebarRow(title: "Integrations", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations") SettingsSidebarRow(title: "Shortcuts", icon: "keyboard.fill", color: "#6366F1").tag("shortcuts") } .listStyle(.sidebar) .scrollContentBackground(.hidden) } } .frame(width: 200) Divider() // Detail panel VStack(alignment: .leading, spacing: 0) { Text(sectionTitle) .font(.title2) .fontWeight(.semibold) .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 12) ScrollView { VStack(alignment: .leading, spacing: 18) { sectionContent } .padding(.horizontal, 20) .padding(.bottom, 16) } if !statusMessage.isEmpty { Divider() Text(statusMessage) .font(.system(size: 12)) .foregroundColor(statusMessage.hasPrefix("❌") ? .red : .secondary) .padding(.horizontal, 20) .padding(.vertical, 8) } } } .onAppear { #if !APPSTORE state.refreshPlanRelayState() #endif guard fetchedModels.isEmpty, let key = KeychainStore.shared.get("anthropic-api-key"), !key.isEmpty else { return } Task { let models = await ClaudeService.fetchModels(apiKey: key) guard !models.isEmpty else { return } await MainActor.run { fetchedModels = models let m = state.claudeModel if models.contains(where: { $0.id == m }) { modelChoice = m customModel = "" } else if modelChoice != Self.customModelTag { modelChoice = Self.customModelTag customModel = m } } } } } // MARK: - Section routing private var sectionTitle: String { switch selectedSection { case "general": return "General" case "activepills": return "Active pills" case "agents": return "Agents" case "chat": return "Chat" case "integrations": return "Integrations" case "shortcuts": return "Shortcuts" default: return "General" } } @ViewBuilder private var sectionContent: some View { switch selectedSection { case "activepills": activePillsSection case "agents": agentsSection case "chat": chatSection case "integrations": integrationsSection case "shortcuts": ShortcutsSettingsView() default: generalSection } } // MARK: - General section @ViewBuilder private var generalSection: some View { GroupBox("Sound") { VStack(alignment: .leading, spacing: 10) { Toggle("Enable sounds", isOn: $state.soundEnabled) HStack(spacing: 8) { Text("Volume") .frame(width: 56, alignment: .leading) Slider(value: $state.soundVolume, in: 0...0.2) .disabled(!state.soundEnabled) Text("\(Int(state.soundVolume / 0.2 * 100)) %") .frame(width: 36, alignment: .trailing) .monospacedDigit() } } .padding(6) } GroupBox("Behavior") { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Text("Close after") TextField("60", value: $state.autoCloseInterval, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 64) Text("s inactive") } HStack(spacing: 8) { Text("Hide after") TextField("3", value: absenceMinutes, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 48) Text("min without movement") } } .padding(6) } GroupBox("Hotkey") { VStack(alignment: .leading, spacing: 10) { Toggle("Show island with shortcut", isOn: $state.hotkeyEnabled) .onChange(of: state.hotkeyEnabled) { _, _ in HotKeyCenter.shared.reregister(.toggleIsland) } if state.hotkeyEnabled { HStack(spacing: 8) { Text("Shortcut") .frame(width: 70, alignment: .leading) ShortcutRecorderButton(flags: $hotkeyFlags, code: $hotkeyCode) .onChange(of: hotkeyFlags) { _, v in state.hotkeyFlags = v HotKeyCenter.shared.reregister(.toggleIsland) } .onChange(of: hotkeyCode) { _, v in state.hotkeyCode = v HotKeyCenter.shared.reregister(.toggleIsland) } Text("presses this → island opens") .font(.system(size: 11)) .foregroundColor(.secondary) } } } .padding(6) } GroupBox("Startup") { Toggle("Launch at Mac startup", isOn: $launchAtStartup) .onChange(of: launchAtStartup) { _, on in toggleStartup(on) } .padding(6) } #if PHONE_LINK GroupBox("iPhone") { VStack(alignment: .leading, spacing: 6) { Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled) .onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) } Text("Sends your sessions to your private iCloud for the BroV iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled) .disabled(!iPhoneSyncEnabled) .onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) } Text("Goes through the BroV relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) #if !APPSTORE Toggle("Let my iPhone send instructions to Claude Code", isOn: $iPhoneInstructionsEnabled) .disabled(!iPhoneSyncEnabled) .onChange(of: iPhoneInstructionsEnabled) { _, on in InstructionRunner.shared.setEnabled(on) } Text("An instruction sent from the iPhone (Face ID required) continues your last Claude Code session in the background, in its folder, with claude --resume. This Mac checks for one every 15 s while this is on.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) #endif } .padding(6) } #endif } // MARK: - Active pills section @ViewBuilder private var activePillsSection: some View { GroupBox { VStack(alignment: .leading, spacing: 10) { Text("Choose the tools you use. BroV only shows what you declare here.") .font(.system(size: 11)) .foregroundColor(.secondary) Text("\(state.activeIntegrations.count)/4 slots used") .font(.system(size: 11)) .foregroundColor(state.activeIntegrations.count >= 4 ? .orange : .secondary) Picker("Main", selection: $state.mainPillId) { ForEach(PillCatalog.available.filter { $0.category == .workspace && !$0.comingSoon }, id: \.id) { def in Text(def.name).tag(def.id) } } .onChange(of: state.mainPillId) { _, newId in state.activeIntegrations.remove(newId) state.loadIntegrationTasks() state.setFocus(newId) } ForEach(PillCategory.allCases, id: \.self) { cat in let catPills = PillCatalog.available.filter { $0.category == cat } if !catPills.isEmpty { Divider() Text(cat.title) .font(.system(size: 11, weight: .semibold)) .foregroundColor(.secondary) ForEach(catPills, id: \.id) { def in pillRow(def) } } } } .padding(6) } } // MARK: - Agents section @ViewBuilder private var agentsSection: some View { GroupBox("Claude Code Hooks") { VStack(alignment: .leading, spacing: 10) { if hookNeedsUpdate { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text("Hooks outdated — update them to answer Claude's questions from the notch") .font(.system(size: 11)) .foregroundColor(.orange) } #if APPSTORE Button("Update hooks") { installHooksAppStore() } #else Button("Update hooks") { installHooks() } #endif } #if APPSTORE Text("~/.claude/coucou/nb-hook") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { Button("Install hooks") { installHooksAppStore() } .buttonStyle(.borderedProminent) Button("Uninstall") { uninstallHooksAppStore() } .buttonStyle(.bordered) } #else Text("nb-hook : \(HookServer.hookScriptPath)") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { Button("Install hooks") { installHooks() } .buttonStyle(.borderedProminent) Button("Uninstall") { uninstallHooks() } .buttonStyle(.bordered) } #endif #if !APPSTORE if showDiff { ScrollView { Text(pendingHookJSON) .font(.system(size: 10, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 140) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { Button("Confirm & write") { confirmInstall() } .buttonStyle(.borderedProminent) Button("Cancel") { showDiff = false; pendingHookJSON = "" } .buttonStyle(.bordered) } } #endif } .padding(6) } #if !APPSTORE GroupBox("Gemini CLI Hooks") { VStack(alignment: .leading, spacing: 10) { Text(geminiHooksInstalled ? "Hooks installed — restart Gemini CLI to activate" : "~/.gemini/settings.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { Button("Install hooks") { triggerGeminiPreview(install: true) } .buttonStyle(.borderedProminent) Button("Uninstall") { triggerGeminiPreview(install: false) } .buttonStyle(.bordered) } if showGeminiDiff { ScrollView { Text(pendingGeminiJSON) .font(.system(size: 10, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 140) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { Button("Confirm & write") { confirmGeminiOp() } .buttonStyle(.borderedProminent) Button("Cancel") { showGeminiDiff = false; pendingGeminiJSON = "" } .buttonStyle(.bordered) } } } .padding(6) } GroupBox("Antigravity Hooks") { VStack(alignment: .leading, spacing: 10) { Text(agyHooksInstalled ? "Hooks installed — restart Antigravity to activate" : "~/.gemini/config/hooks.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { Button("Install hooks") { triggerAgyPreview(install: true) } .buttonStyle(.borderedProminent) Button("Uninstall") { triggerAgyPreview(install: false) } .buttonStyle(.bordered) } if showAgyDiff { ScrollView { Text(pendingAgyJSON) .font(.system(size: 10, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 140) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { Button("Confirm & write") { confirmAgyOp() } .buttonStyle(.borderedProminent) Button("Cancel") { showAgyDiff = false; pendingAgyJSON = "" } .buttonStyle(.bordered) } } } .padding(6) } GroupBox("Codex Hooks") { VStack(alignment: .leading, spacing: 10) { Text(codexHooksInstalled ? "Hooks installed — open Codex and run /hooks or open Hooks in the app's settings to trust them" : "~/.codex/hooks.json") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) HStack(spacing: 10) { Button("Install hooks") { triggerCodexPreview(install: true) } .buttonStyle(.borderedProminent) Button("Uninstall") { triggerCodexPreview(install: false) } .buttonStyle(.bordered) } if showCodexDiff { ScrollView { Text(pendingCodexJSON) .font(.system(size: 10, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 140) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { Button("Confirm & write") { confirmCodexOp() } .buttonStyle(.borderedProminent) Button("Cancel") { showCodexDiff = false; pendingCodexJSON = "" } .buttonStyle(.bordered) } } } .padding(6) } GroupBox("Plan usage") { VStack(alignment: .leading, spacing: 10) { Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. BroV adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) Toggle("Show in the notch", isOn: Binding( get: { state.showPlanInNotch || planTogglePending }, set: { on in if on { if state.planRelayInstalled { state.showPlanInNotch = true } else { planTogglePending = true installStatusLine() } } else { state.showPlanInNotch = false planTogglePending = false } } )) HStack(spacing: 10) { if state.planRelayInstalled { Text("Relay: installed") .font(.system(size: 11)) .foregroundColor(.secondary) Button("Uninstall relay") { uninstallStatusLine() } .buttonStyle(.bordered) } else { Text("Relay: not installed") .font(.system(size: 11)) .foregroundColor(.secondary) Button("Install relay") { installStatusLine() } .buttonStyle(.borderedProminent) } } if showStatusLineDiff { ScrollView { Text(pendingStatusLineJSON) .font(.system(size: 10, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 100) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(6) HStack { Button("Confirm & write") { confirmStatusLine() } .buttonStyle(.borderedProminent) Button("Cancel") { showStatusLineDiff = false pendingStatusLineJSON = "" planTogglePending = false } .buttonStyle(.bordered) } } } .padding(6) } #endif } // MARK: - Chat section @ViewBuilder private var chatSection: some View { GroupBox("Claude Code (your subscription, no API key)") { VStack(alignment: .leading, spacing: 8) { HStack { TextField("Folder the chat runs in", text: $state.chatWorkingDirectory) .textFieldStyle(.roundedBorder) Button("Choose…") { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { state.chatWorkingDirectory = url.path ClaudeCodeCLI.shared.newConversation() } } } Picker("Model", selection: $state.claudeCodeModel) { ForEach(ClaudeCodeCLI.models, id: \.id) { m in Text(m.label).tag(m.id) } } Picker("Permissions", selection: $state.claudePermissionMode) { Text("Ask in the notch").tag("default") Text("Accept edits").tag("acceptEdits") Text("Plan only").tag("plan") Text("Bypass (dangerous)").tag("bypassPermissions") } TextField("Path to claude (empty = auto)", text: $state.claudeBinaryPath) .textFieldStyle(.roundedBorder) let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath) Text(found.map { "✓ Using \($0)" } ?? "✗ claude not found — install Claude Code or set the path.") .font(.system(size: 11)) .foregroundColor(found == nil ? .red : .secondary) Text("The chat runs `claude -p` in this folder and keeps one conversation until you start a new one. Your MCP servers, skills and CLAUDE.md come along.") .font(.system(size: 11)) .foregroundColor(.secondary) } .padding(6) } GroupBox("Anthropic API") { VStack(alignment: .leading, spacing: 8) { SecureField("API key (sk-ant-…)", text: $apiKey) .textFieldStyle(.roundedBorder) Button("Save") { KeychainStore.shared.set("anthropic-api-key", value: apiKey) statusMessage = "✓ Key saved." } .buttonStyle(.borderedProminent) Divider().padding(.vertical, 2) Picker("Model", selection: $modelChoice) { ForEach(displayModels, id: \.id) { preset in Text(preset.label).tag(preset.id) } Text("Custom…").tag(Self.customModelTag) } .onChange(of: modelChoice) { _, choice in if choice != Self.customModelTag { state.claudeModel = choice } else { applyCustomModel(customModel) } } if modelChoice == Self.customModelTag { TextField("Model ID (e.g. claude-sonnet-4-6)", text: $customModel) .textFieldStyle(.roundedBorder) .onChange(of: customModel) { _, value in applyCustomModel(value) } } Text("Used by the chat. The list comes from your Anthropic account.") .font(.system(size: 11)) .foregroundColor(.secondary) } .padding(6) } GroupBox("Chat — other providers") { VStack(alignment: .leading, spacing: 12) { Text("To use Google Gemini or OpenAI from the chat. Keys are stored in the Keychain.") .font(.system(size: 12)) .foregroundColor(.secondary) HStack(spacing: 8) { Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8) Text("Google AI").font(.system(size: 12, weight: .semibold)) } SecureField("API key (AI Studio)", text: $googleKey) .textFieldStyle(.roundedBorder) Button("Save") { KeychainStore.shared.set("google-api-key", value: googleKey) statusMessage = "✓ Google key saved." } .buttonStyle(.borderedProminent) Divider() HStack(spacing: 8) { Circle().fill(Color(hex: "#10A37F")).frame(width: 8, height: 8) Text("OpenAI").font(.system(size: 12, weight: .semibold)) } SecureField("API key (sk-…)", text: $openAIKey) .textFieldStyle(.roundedBorder) Button("Save") { KeychainStore.shared.set("openai-api-key", value: openAIKey) statusMessage = "✓ OpenAI key saved." } .buttonStyle(.borderedProminent) } .padding(.vertical, 4) } GroupBox("Local models") { VStack(alignment: .leading, spacing: 12) { Text("Connect to a local model server. No API key needed.") .font(.system(size: 12)) .foregroundColor(.secondary) // ── Ollama ────────────────────────────────────────────────────── HStack(spacing: 8) { Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8) Text("Ollama").font(.system(size: 12, weight: .semibold)) if !state.ollamaServerURL.isEmpty { Text("Connected") .font(.system(size: 10)) .foregroundColor(Color(hex: "#22C55E")) } } if state.ollamaServerURL.isEmpty { TextField("http://127.0.0.1:11434", text: $ollamaURL) .textFieldStyle(.roundedBorder) Button(connectingOllama ? "Connecting…" : "Connect") { Task { await connectLocal(provider: .ollama) } } .buttonStyle(.borderedProminent) .disabled(connectingOllama) } else { Text(state.ollamaServerURL) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) Button("Disconnect") { state.ollamaServerURL = "" ollamaURL = "" state.fetchedProviderModels[.ollama] = nil state.providerModelFetchError[.ollama] = nil if state.chatProvider == .ollama { state.chatProvider = .claudeCode } statusMessage = "Ollama disconnected." } .buttonStyle(.bordered) } Divider() // ── LM Studio ─────────────────────────────────────────────────── HStack(spacing: 8) { Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8) Text("LM Studio").font(.system(size: 12, weight: .semibold)) if !state.lmstudioServerURL.isEmpty { Text("Connected") .font(.system(size: 10)) .foregroundColor(Color(hex: "#22C55E")) } } if state.lmstudioServerURL.isEmpty { TextField("http://127.0.0.1:1234", text: $lmstudioURL) .textFieldStyle(.roundedBorder) Button(connectingLMStudio ? "Connecting…" : "Connect") { Task { await connectLocal(provider: .lmstudio) } } .buttonStyle(.borderedProminent) .disabled(connectingLMStudio) } else { Text(state.lmstudioServerURL) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) Button("Disconnect") { state.lmstudioServerURL = "" lmstudioURL = "" state.fetchedProviderModels[.lmstudio] = nil state.providerModelFetchError[.lmstudio] = nil if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode } statusMessage = "LM Studio disconnected." } .buttonStyle(.bordered) } } .padding(.vertical, 4) } } // MARK: - Integrations section @ViewBuilder private var integrationsSection: some View { GroupBox { VStack(alignment: .leading, spacing: 14) { // Resend VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#22C55E")).frame(width: 8, height: 8) Text("Resend").font(.system(size: 12, weight: .semibold)) } SecureField("API key (re_…)", text: $resendKey) .textFieldStyle(.roundedBorder) TextField("From address (you@yourdomain.com)", text: $resendFrom) .textFieldStyle(.roundedBorder) } // n8n VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#F29B38")).frame(width: 8, height: 8) Text("n8n").font(.system(size: 12, weight: .semibold)) } TextField("Instance URL (https://…)", text: $n8nUrl) .textFieldStyle(.roundedBorder) SecureField("API key", text: $n8nKey) .textFieldStyle(.roundedBorder) IntegrationFilterRow( label: "Workflows", items: n8nWorkflows, filter: $state.n8nWorkflowFilter, loading: loadingN8n, onLoad: loadN8nWorkflows ) } // Vercel VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#7C5CFF")).frame(width: 8, height: 8) Text("Vercel").font(.system(size: 12, weight: .semibold)) } SecureField("Token", text: $vercelToken) .textFieldStyle(.roundedBorder) IntegrationFilterRow( label: "Projects", items: vercelProjects, filter: $state.vercelProjectFilter, loading: loadingVercel, onLoad: loadVercelProjects ) } // GitHub VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#F4505E")).frame(width: 8, height: 8) Text("GitHub").font(.system(size: 12, weight: .semibold)) } SecureField("Personal Access Token", text: $githubToken) .textFieldStyle(.roundedBorder) Text("Classic token with repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") .font(.system(size: 10)) .foregroundColor(Color(hex: "#8E939C")) } // Stripe VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#0570DE")).frame(width: 8, height: 8) Text("Stripe").font(.system(size: 12, weight: .semibold)) } SecureField("Secret key (sk_live_… or sk_test_…)", text: $stripeKey) .textFieldStyle(.roundedBorder) } // Cal.com VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#C9956A")).frame(width: 8, height: 8) Text("Cal.com").font(.system(size: 12, weight: .semibold)) } SecureField("API key (cal_live_…)", text: $calcomKey) .textFieldStyle(.roundedBorder) } // Notion VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Circle().fill(Color(hex: "#E8E8E8")).frame(width: 8, height: 8) Text("Notion").font(.system(size: 12, weight: .semibold)) } SecureField("Integration token (secret_…)", text: $notionKey) .textFieldStyle(.roundedBorder) } Button("Save integrations") { saveIntegrations() } .buttonStyle(.borderedProminent) } .padding(6) } } // MARK: - Actions private func applyCustomModel(_ value: String) { let id = value.trimmingCharacters(in: .whitespacesAndNewlines) if !id.isEmpty { state.claudeModel = id } } private func toggleStartup(_ on: Bool) { do { if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } } catch { statusMessage = "❌ Startup: \(error.localizedDescription)" launchAtStartup = !on } } // MARK: - App Store: hooks via NSOpenPanel + security-scoped bookmark #if APPSTORE private func pickClaudeFolder(prompt: String) -> URL? { let panel = NSOpenPanel() panel.message = "Select your .claude folder (press ⇧⌘. to show hidden files)" panel.prompt = prompt panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.showsHiddenFiles = true let realHomePath = getpwuid(getuid()).flatMap { String(cString: $0.pointee.pw_dir, encoding: .utf8) } ?? "/Users/\(NSUserName())" panel.directoryURL = URL(fileURLWithPath: realHomePath) guard panel.runModal() == .OK, let url = panel.url else { return nil } guard url.lastPathComponent == ".claude" else { statusMessage = "❌ Select the .claude folder (hidden, in your Home directory)." return nil } return url } private func installHooksAppStore() { guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } let alert = NSAlert() alert.messageText = "Install BroV hooks in ~/.claude?" alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)" alert.addButton(withTitle: "Install") alert.addButton(withTitle: "Cancel") alert.alertStyle = .informational guard alert.runModal() == .alertFirstButtonReturn else { return } do { try HookServer.shared.installAndWriteClaudeHooksAppStore(claudeURL: claudeURL) hookNeedsUpdate = false statusMessage = "✓ Hooks installed — restart VS Code to activate." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func uninstallHooksAppStore() { guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } do { try HookServer.shared.uninstallClaudeHooksAppStore(claudeURL: claudeURL) statusMessage = "✓ Hooks removed." } catch { statusMessage = "❌ \(error.localizedDescription)" } } #endif private func connectLocal(provider: ChatProvider) async { let rawURL = provider == .ollama ? ollamaURL : lmstudioURL let candidate = rawURL.isEmpty ? (provider == .ollama ? "http://127.0.0.1:11434" : "http://127.0.0.1:1234") : rawURL let normalised = LocalChat.normaliseURL(candidate) guard normalised.hasPrefix("http://") || normalised.hasPrefix("https://") else { statusMessage = "Only http:// and https:// URLs are supported." return } if provider == .ollama { connectingOllama = true } else { connectingLMStudio = true } statusMessage = "" let result = await LocalChat.fetchModelsResult(baseURL: normalised) if provider == .ollama { connectingOllama = false } else { connectingLMStudio = false } let name = provider == .ollama ? "Ollama" : "LM Studio" switch result { case .success(let models) where models.isEmpty: statusMessage = "No models yet — download one in \(name) first." case .success(let models): if provider == .ollama { state.ollamaServerURL = normalised ollamaURL = normalised state.fetchedProviderModels[.ollama] = nil state.providerModelFetchError[.ollama] = nil } else { state.lmstudioServerURL = normalised lmstudioURL = normalised state.fetchedProviderModels[.lmstudio] = nil state.providerModelFetchError[.lmstudio] = nil } statusMessage = "✓ Connected · \(models.count) model\(models.count == 1 ? "" : "s")" case .failure: statusMessage = "Couldn't reach \(name) at \(normalised). Is it running?" } } private func installHooks() { do { pendingHookJSON = try HookServer.shared.previewClaudeHooks() showDiff = true statusMessage = "Review the JSON below before confirming." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func confirmInstall() { do { try HookServer.shared.writeClaudeHooks() showDiff = false statusMessage = "✓ Hooks installed in ~/.claude/settings.json" pendingHookJSON = "" hookNeedsUpdate = false } catch { statusMessage = "❌ Write error: \(error.localizedDescription)" } } private func uninstallHooks() { do { try HookServer.shared.uninstallClaudeHooks() statusMessage = "✓ Hooks removed." } catch { statusMessage = "❌ \(error.localizedDescription)" } } #if !APPSTORE private func triggerGeminiPreview(install: Bool) { do { geminiPendingInstall = install pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install) showGeminiDiff = true statusMessage = "Review the JSON below before confirming." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func confirmGeminiOp() { do { try HookServer.shared.writeGeminiHooks() showGeminiDiff = false pendingGeminiJSON = "" geminiHooksInstalled = geminiPendingInstall statusMessage = geminiPendingInstall ? "✓ Gemini CLI hooks installed in ~/.gemini/settings.json" : "✓ Gemini CLI hooks removed." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func triggerAgyPreview(install: Bool) { do { agyPendingInstall = install pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install) showAgyDiff = true statusMessage = "Review the JSON below before confirming." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func confirmAgyOp() { do { try HookServer.shared.writeAgyHooks() showAgyDiff = false pendingAgyJSON = "" agyHooksInstalled = agyPendingInstall statusMessage = agyPendingInstall ? "✓ Antigravity hooks installed in ~/.gemini/config/hooks.json" : "✓ Antigravity hooks removed." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func triggerCodexPreview(install: Bool) { do { codexPendingInstall = install pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install) showCodexDiff = true statusMessage = "Review the JSON below before confirming." } catch let e as NSError where e.domain == "BroVNoop" { statusMessage = e.localizedDescription } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func confirmCodexOp() { do { try HookServer.shared.writeCodexHooks() showCodexDiff = false pendingCodexJSON = "" codexHooksInstalled = codexPendingInstall statusMessage = codexPendingInstall ? "✓ Codex hooks installed — run /hooks in Codex or open Hooks in the app's settings to trust them." : "✓ Codex hooks removed." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func installStatusLine() { do { pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true) showStatusLineDiff = true statusLinePendingInstall = true statusMessage = "Review the JSON below before confirming." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func uninstallStatusLine() { do { pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false) showStatusLineDiff = true statusLinePendingInstall = false statusMessage = "Review the JSON below before confirming." } catch { statusMessage = "❌ \(error.localizedDescription)" } } private func confirmStatusLine() { do { try HookServer.shared.writeStatusLine() showStatusLineDiff = false pendingStatusLineJSON = "" state.refreshPlanRelayState() if planTogglePending { state.showPlanInNotch = true planTogglePending = false } if !statusLinePendingInstall { state.showPlanInNotch = false } statusMessage = statusLinePendingInstall ? "✓ Status line installed." : "✓ Status line removed." } catch { planTogglePending = false statusMessage = "❌ \(error.localizedDescription)" } } #endif private func saveIntegrations() { saveKey("resend-api-key", value: resendKey) saveKey("resend-from", value: resendFrom) saveKey("n8n-url", value: n8nUrl) saveKey("n8n-api-key", value: n8nKey) saveKey("vercel-token", value: vercelToken) // Detect GitHub token changes before writing let prevGithubToken = KeychainStore.shared.get("github-token") saveKey("github-token", value: githubToken) let nextGithubToken = KeychainStore.shared.get("github-token") if nextGithubToken != prevGithubToken { AppState.shared.githubPulse = nil AppState.shared.githubActivity = nil if nextGithubToken == nil { AppState.shared.githubStats = nil } if nextGithubToken != nil { GithubPoller.shared.triggerPulseNow() GithubPoller.shared.refreshActivityIfStale() } } saveKey("stripe-api-key", value: stripeKey) saveKey("calcom-api-key", value: calcomKey) saveKey("notion-api-key", value: notionKey) statusMessage = "✓ Integration keys saved." } private func saveKey(_ key: String, value: String) { if value.isEmpty { KeychainStore.shared.remove(key) } else { KeychainStore.shared.set(key, value: value) } } // MARK: - Vercel project list private func loadVercelProjects() { guard let token = KeychainStore.shared.get("vercel-token") else { statusMessage = "❌ Save Vercel token first." return } loadingVercel = true guard let url = URL(string: "https://api.vercel.com/v9/projects?limit=100") else { return } var req = URLRequest(url: url, timeoutInterval: 10) req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") URLSession.shared.dataTask(with: req) { data, response, _ in let names: [String] if let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let projects = json["projects"] as? [[String: Any]] { names = projects.compactMap { $0["name"] as? String }.sorted() } else { names = [] } DispatchQueue.main.async { self.vercelProjects = names self.loadingVercel = false if names.isEmpty { self.statusMessage = "❌ No Vercel projects found." } } }.resume() } // MARK: - n8n workflow list private func loadN8nWorkflows() { guard let apiKey = KeychainStore.shared.get("n8n-api-key"), let rawBase = KeychainStore.shared.get("n8n-url") else { statusMessage = "❌ Save n8n URL and API key first." return } loadingN8n = true let base = rawBase.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let urls = ["\(base)/api/v1/workflows?limit=100", "\(base)/rest/workflows?limit=100"] fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: 0) } private func fetchN8nWorkflows(urls: [String], apiKey: String, idx: Int) { guard idx < urls.count, let url = URL(string: urls[idx]) else { DispatchQueue.main.async { self.loadingN8n = false; self.statusMessage = "❌ No n8n workflows found." } return } var req = URLRequest(url: url, timeoutInterval: 10) req.setValue(apiKey, forHTTPHeaderField: "X-N8N-API-KEY") URLSession.shared.dataTask(with: req) { data, response, _ in let code = (response as? HTTPURLResponse)?.statusCode ?? 0 guard let data, code == 200 else { self.fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: idx + 1) return } let items: [[String: Any]] if let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let arr = obj["data"] as? [[String: Any]] { items = arr } else if let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { items = arr } else { items = [] } let names = items.compactMap { $0["name"] as? String }.sorted() DispatchQueue.main.async { self.n8nWorkflows = names self.loadingN8n = false if names.isEmpty { self.statusMessage = "❌ No n8n workflows found." } } }.resume() } @ViewBuilder private func pillRow(_ def: PillDefinition) -> some View { let isMain = def.id == state.mainPillId let isOn = state.activeIntegrations.contains(def.id) let atMax = state.activeIntegrations.count >= 4 && !isOn && !isMain let hint: String? = { if isMain { return nil } if def.comingSoon { return "Coming soon" } #if !APPSTORE if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Hooks not installed" } if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Hooks not installed" } if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Hooks not installed" } #endif if def.category == .ai { if let provider = ChatProvider(pillID: def.id), provider.isLocal { let url = provider == .ollama ? state.ollamaServerURL : state.lmstudioServerURL if url.isEmpty { return "Not connected" } } else { let keyId = def.id == "ai_anthropic" ? "anthropic-api-key" : def.id == "ai_google" ? "google-api-key" : "openai-api-key" if KeychainStore.shared.get(keyId) == nil { return "Key not configured" } } } return nil }() HStack(spacing: 8) { Circle() .fill(Color(hex: def.color)) .frame(width: 10, height: 10) Text(def.name) .font(.system(size: 12)) .foregroundColor(atMax ? .secondary : .primary) Spacer() if isMain { Text("Main") .font(.system(size: 11)) .foregroundColor(.secondary) } else { if let h = hint { Text(h) .font(.system(size: 11)) .foregroundColor(.secondary) } Toggle("", isOn: Binding( get: { isOn }, set: { _ in state.toggleIntegration(def.id) } )) .labelsHidden() .disabled(atMax) } } } } // MARK: - Sidebar background (NSVisualEffectView .sidebar) struct SidebarBackground: NSViewRepresentable { func makeNSView(context: Context) -> NSVisualEffectView { let v = NSVisualEffectView() v.material = .sidebar v.blendingMode = .behindWindow v.state = .active return v } func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } // MARK: - Sidebar row (System Settings style icon) struct SettingsSidebarRow: View { let title: String let icon: String let color: String var body: some View { Label { Text(title) } icon: { Image(systemName: icon) .font(.system(size: 11, weight: .semibold)) .foregroundColor(.white) .frame(width: 20, height: 20) .background(RoundedRectangle(cornerRadius: 5).fill(Color(hex: color))) } } } // MARK: - Integration filter row (reusable for Vercel / n8n) struct IntegrationFilterRow: View { let label: String let items: [String] @Binding var filter: Set let loading: Bool let onLoad: () -> Void var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(label) .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() if loading { ProgressView().scaleEffect(0.6) } else { Button(items.isEmpty ? "Load list" : "Refresh") { onLoad() } .buttonStyle(.bordered) .controlSize(.mini) } if !filter.isEmpty { Button("Clear") { filter = [] } .buttonStyle(.bordered) .controlSize(.mini) .foregroundColor(.secondary) } } if !items.isEmpty { VStack(alignment: .leading, spacing: 2) { ForEach(items, id: \.self) { item in Toggle(item, isOn: Binding( get: { filter.isEmpty || filter.contains(item) }, set: { on in if on { filter.insert(item) } else { if filter.isEmpty { filter = Set(items).subtracting([item]) } else { filter.remove(item) } if filter.count == items.count { filter = [] } } } )) .font(.system(size: 11)) .toggleStyle(.checkbox) } } .padding(.leading, 4) if !filter.isEmpty { Text("Watching \(filter.count) of \(items.count)") .font(.system(size: 10)) .foregroundColor(.secondary) } } } } } // MARK: - Shortcut recorder button struct ShortcutRecorderButton: View { @Binding var flags: UInt @Binding var code: UInt16 @State private var isRecording = false var body: some View { Button { guard !isRecording else { return } isRecording = true var token: Any? token = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let mods = event.modifierFlags.intersection([.command, .control, .option, .shift]) guard !mods.isEmpty else { return event } DispatchQueue.main.async { self.flags = mods.rawValue self.code = event.keyCode self.isRecording = false if let t = token { NSEvent.removeMonitor(t) } } return nil } } label: { Text(isRecording ? "Press keys…" : shortcutLabel) .font(.system(size: 11, design: .monospaced)) .padding(.horizontal, 8).padding(.vertical, 3) .background(isRecording ? Color.accentColor.opacity(0.12) : Color(NSColor.controlBackgroundColor)) .cornerRadius(5) .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.gray.opacity(0.3), lineWidth: 1)) } .buttonStyle(.plain) } private var shortcutLabel: String { let f = NSEvent.ModifierFlags(rawValue: flags) var s = "" if f.contains(.control) { s += "⌃" } if f.contains(.option) { s += "⌥" } if f.contains(.shift) { s += "⇧" } if f.contains(.command) { s += "⌘" } s += keyChar(code) return s.isEmpty ? "None" : s } private func keyChar(_ c: UInt16) -> String { let map: [UInt16: String] = [ 0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X", 8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y", 17:"T", 31:"O", 32:"U", 34:"I", 37:"L", 38:"J", 40:"K", 45:"N", 46:"M", 49:"Space", 50:"`", 27:"-" ] return map[c] ?? "·" } }