Files
brov-macbook/NotchBuddy/Sources/App/SettingsView.swift
T
2026-10-06 19:34:32 +03:00

1423 lines
60 KiB
Swift

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<Double> {
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("Coucou")
.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 Coucou 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 Coucou 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. Coucou 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. Coucou 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("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 = .anthropic }
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 = .anthropic }
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 Coucou 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 == "CoucouNoop" {
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 == "CoucouNoop" {
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 == "CoucouNoop" {
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<String>
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] ?? "·"
}
}