56367ef9cd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1520 lines
70 KiB
Swift
1520 lines
70 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()
|
||
/// Inline success text shown right under each hooks GroupBox's buttons (key: "claude", "gemini", "agy", "codex", "statusline").
|
||
@State private var hookResult: [String: String] = [:]
|
||
|
||
#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("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: "Основные", icon: "gearshape.fill", color: "#8E939C").tag("general")
|
||
SettingsSidebarRow(title: "Плашки", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills")
|
||
SettingsSidebarRow(title: "Агенты", icon: "terminal.fill", color: "#3B9EFF").tag("agents")
|
||
SettingsSidebarRow(title: "Чат", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat")
|
||
SettingsSidebarRow(title: "Интеграции", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations")
|
||
SettingsSidebarRow(title: "Сочетания", 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 "Основные"
|
||
case "activepills": return "Плашки"
|
||
case "agents": return "Агенты"
|
||
case "chat": return "Чат"
|
||
case "integrations": return "Интеграции"
|
||
case "shortcuts": return "Сочетания клавиш"
|
||
default: return "Основные"
|
||
}
|
||
}
|
||
|
||
@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("Надёжность") {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Toggle("Поднимать BroV, если он упал, и запускать при входе в систему", isOn: Binding(
|
||
get: { UserDefaults.standard.bool(forKey: BroVLaunchAgent.enabledKey) },
|
||
set: { on in
|
||
UserDefaults.standard.set(on, forKey: BroVLaunchAgent.enabledKey)
|
||
if on { BroVLaunchAgent.install(handOver: !BroVLaunchAgent.isManaged) }
|
||
else if BroVLaunchAgent.isInstalled { BroVLaunchAgent.uninstall() }
|
||
}))
|
||
Text("Если выйти через меню, BroV не вернётся. Работает для копии в ~/Applications.")
|
||
.font(.system(size: 11)).foregroundColor(.secondary)
|
||
Toggle("Сразу поднимать упавшие сессии Claude Code и Codex", isOn: Binding(
|
||
get: { UserDefaults.standard.bool(forKey: AgentWatch.autoKey) },
|
||
set: { UserDefaults.standard.set($0, forKey: AgentWatch.autoKey) }))
|
||
Text("Сессия считается упавшей, если её процесс пропал посреди работы. Без галочки в пилюле появится кнопка «поднять». Не чаще 2 раз за 10 минут.")
|
||
.font(.system(size: 11)).foregroundColor(.secondary)
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Звук") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Toggle("Включить звуки", isOn: $state.soundEnabled)
|
||
HStack(spacing: 8) {
|
||
Text("Громкость")
|
||
.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("Поведение") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 8) {
|
||
Text("Закрывать через")
|
||
TextField("60", value: $state.autoCloseInterval, format: .number)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 64)
|
||
Text("с бездействия")
|
||
}
|
||
HStack(spacing: 8) {
|
||
Text("Скрывать через")
|
||
TextField("3", value: absenceMinutes, format: .number)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 48)
|
||
Text("мин без движения")
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Горячая клавиша") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Toggle("Показывать остров по сочетанию", isOn: $state.hotkeyEnabled)
|
||
.onChange(of: state.hotkeyEnabled) { _, _ in
|
||
HotKeyCenter.shared.reregister(.toggleIsland)
|
||
}
|
||
if state.hotkeyEnabled {
|
||
HStack(spacing: 8) {
|
||
Text("Сочетание")
|
||
.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("нажатие → остров открывается")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Запуск") {
|
||
Toggle("Запускать при входе в систему", isOn: $launchAtStartup)
|
||
.onChange(of: launchAtStartup) { _, on in toggleStartup(on) }
|
||
.padding(6)
|
||
}
|
||
|
||
#if PHONE_LINK
|
||
GroupBox("iPhone") {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Toggle("Показывать сессии агентов на iPhone", isOn: $iPhoneSyncEnabled)
|
||
.onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) }
|
||
Text("Отправляет сессии в личный iCloud для приложения BroV на iPhone. Названия проектов, команды и вопросы шифруются ключами iCloud. При отключении они удаляются из iCloud.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Toggle("Переносить BroV в Dynamic Island на iPhone, когда Mac заблокирован", isOn: $iPhoneLiveActivityEnabled)
|
||
.disabled(!iPhoneSyncEnabled)
|
||
.onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) }
|
||
Text("Идёт через ретранслятор BroV в push-сервис Apple. Передаются только имя и состояние агента: без названия проекта, команд и путей.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
#if !APPSTORE
|
||
Toggle("Разрешить iPhone отправлять инструкции в Claude Code", isOn: $iPhoneInstructionsEnabled)
|
||
.disabled(!iPhoneSyncEnabled)
|
||
.onChange(of: iPhoneInstructionsEnabled) { _, on in InstructionRunner.shared.setEnabled(on) }
|
||
Text("Инструкция с iPhone (нужен Face ID) продолжает последнюю сессию Claude Code в фоне, в её папке, через claude --resume. Пока опция включена, Mac проверяет новые инструкции каждые 15 с.")
|
||
.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("Выберите инструменты, которыми пользуетесь. BroV показывает только то, что отмечено здесь.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
|
||
Text("Занято слотов: \(state.activeIntegrations.count)/4")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(state.activeIntegrations.count >= 4 ? .orange : .secondary)
|
||
|
||
Picker("Основная", 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") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if hookNeedsUpdate {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.orange)
|
||
Text("Хуки устарели — обновите их, чтобы отвечать на вопросы Claude из выреза")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.orange)
|
||
}
|
||
#if APPSTORE
|
||
Button("Обновить хуки") { installHooksAppStore() }
|
||
#else
|
||
Button("Обновить хуки") { installHooks() }
|
||
#endif
|
||
}
|
||
#if APPSTORE
|
||
Text("~/.claude/coucou/nb-hook")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 10) {
|
||
Button("Установить хуки") { installHooksAppStore() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Удалить") { uninstallHooksAppStore() }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
#else
|
||
Text("nb-hook : \(HookServer.hookScriptPath)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 10) {
|
||
Button("Установить хуки") { installHooks() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Удалить") { uninstallHooks() }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
#endif
|
||
hookResultText("claude")
|
||
|
||
#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("Подтвердить и записать") { confirmInstall() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Отмена") { showDiff = false; pendingHookJSON = "" }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
#if !APPSTORE
|
||
GroupBox("Хуки Gemini CLI") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(geminiHooksInstalled
|
||
? "Хуки установлены — перезапустите Gemini CLI"
|
||
: "~/.gemini/settings.json")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 10) {
|
||
Button("Установить хуки") { triggerGeminiPreview(install: true) }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Удалить") { triggerGeminiPreview(install: false) }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
hookResultText("gemini")
|
||
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("Подтвердить и записать") { confirmGeminiOp() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Отмена") { showGeminiDiff = false; pendingGeminiJSON = "" }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Хуки Antigravity") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(agyHooksInstalled
|
||
? "Хуки установлены — перезапустите Antigravity"
|
||
: "~/.gemini/config/hooks.json")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 10) {
|
||
Button("Установить хуки") { triggerAgyPreview(install: true) }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Удалить") { triggerAgyPreview(install: false) }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
hookResultText("agy")
|
||
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("Подтвердить и записать") { confirmAgyOp() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Отмена") { showAgyDiff = false; pendingAgyJSON = "" }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Хуки Codex") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text(codexHooksInstalled
|
||
? "Хуки установлены — в Codex выполните /hooks или откройте Hooks в настройках, чтобы доверить им"
|
||
: "~/.codex/hooks.json")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 10) {
|
||
Button("Установить хуки") { triggerCodexPreview(install: true) }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Удалить") { triggerCodexPreview(install: false) }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
hookResultText("codex")
|
||
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("Подтвердить и записать") { confirmCodexOp() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Отмена") { showCodexDiff = false; pendingCodexJSON = "" }
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Лимиты плана") {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("Показывает расход плана Claude (5-часовой и недельный лимиты) в шапке выреза. BroV добавляет ретранслятор строки состояния в ~/.claude/settings.json. Если строка состояния уже есть, она продолжит работать как раньше. Только для планов Pro и Max.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Toggle("Показывать в вырезе", 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("Ретранслятор: установлен")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
Button("Удалить ретранслятор") { uninstallStatusLine() }
|
||
.buttonStyle(.bordered)
|
||
} else {
|
||
Text("Ретранслятор: не установлен")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
Button("Установить ретранслятор") { installStatusLine() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
hookResultText("statusline")
|
||
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("Подтвердить и записать") { confirmStatusLine() }
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Отмена") {
|
||
showStatusLineDiff = false
|
||
pendingStatusLineJSON = ""
|
||
planTogglePending = false
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Chat section
|
||
|
||
@ViewBuilder private var chatSection: some View {
|
||
GroupBox("Claude Code (ваша подписка, без API-ключа)") {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
TextField("Папка, в которой работает чат", text: $state.chatWorkingDirectory)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("Выбрать…") {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseDirectories = true
|
||
panel.canChooseFiles = false
|
||
panel.allowsMultipleSelection = false
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
state.chatWorkingDirectory = url.path
|
||
ChatTabs.shared.cli(for: state.activeTabId).newConversation()
|
||
}
|
||
}
|
||
}
|
||
Picker("Ширина чата", selection: $state.chatWidth) {
|
||
ForEach(AppState.chatWidthOptions, id: \.self) { w in
|
||
Text(w == 640 ? "640 (как главный экран)" : "\(Int(w))").tag(w)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
Picker("Модель", selection: $state.claudeCodeModel) {
|
||
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
|
||
Text(m.label).tag(m.id)
|
||
}
|
||
}
|
||
Picker("Разрешения", selection: $state.claudePermissionMode) {
|
||
Text("Спрашивать в вырезе").tag("default")
|
||
Text("Принимать правки").tag("acceptEdits")
|
||
Text("Только план").tag("plan")
|
||
Text("Без проверок (опасно)").tag("bypassPermissions")
|
||
}
|
||
TextField("Путь к claude (пусто = авто)", text: $state.claudeBinaryPath)
|
||
.textFieldStyle(.roundedBorder)
|
||
let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath)
|
||
Text(found.map { "✓ Используется \($0)" } ?? "✗ claude не найден — установите Claude Code или укажите путь.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(found == nil ? .red : .secondary)
|
||
Text("Чат запускает `claude -p` в этой папке и ведёт один диалог, пока вы не начнёте новый. Ваши MCP-серверы, скиллы и CLAUDE.md подключаются автоматически.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Anthropic API") {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
SecureField("API-ключ (sk-ant-…)", text: $apiKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("Сохранить") {
|
||
KeychainStore.shared.set("anthropic-api-key", value: apiKey)
|
||
statusMessage = "✓ Ключ сохранён."
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
|
||
Divider().padding(.vertical, 2)
|
||
|
||
Picker("Модель", selection: $modelChoice) {
|
||
ForEach(displayModels, id: \.id) { preset in
|
||
Text(preset.label).tag(preset.id)
|
||
}
|
||
Text("Своя…").tag(Self.customModelTag)
|
||
}
|
||
.onChange(of: modelChoice) { _, choice in
|
||
if choice != Self.customModelTag {
|
||
state.claudeModel = choice
|
||
} else {
|
||
applyCustomModel(customModel)
|
||
}
|
||
}
|
||
|
||
if modelChoice == Self.customModelTag {
|
||
TextField("ID модели (напр. claude-sonnet-4-6)", text: $customModel)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onChange(of: customModel) { _, value in applyCustomModel(value) }
|
||
}
|
||
|
||
Text("Используется в чате. Список загружается из аккаунта Anthropic.")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(6)
|
||
}
|
||
|
||
GroupBox("Чат — другие провайдеры") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Для работы с Google Gemini или OpenAI из чата. Ключи хранятся в Связке ключей.")
|
||
.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-ключ (AI Studio)", text: $googleKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("Сохранить") {
|
||
KeychainStore.shared.set("google-api-key", value: googleKey)
|
||
statusMessage = "✓ Ключ Google сохранён."
|
||
}
|
||
.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-ключ (sk-…)", text: $openAIKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("Сохранить") {
|
||
KeychainStore.shared.set("openai-api-key", value: openAIKey)
|
||
statusMessage = "✓ Ключ OpenAI сохранён."
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
GroupBox("Локальные модели") {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Подключение к локальному серверу моделей. API-ключ не нужен.")
|
||
.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("Подключено")
|
||
.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 ? "Подключение…" : "Подключить") {
|
||
Task { await connectLocal(provider: .ollama) }
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(connectingOllama)
|
||
} else {
|
||
Text(state.ollamaServerURL)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Button("Отключить") {
|
||
state.ollamaServerURL = ""
|
||
ollamaURL = ""
|
||
state.fetchedProviderModels[.ollama] = nil
|
||
state.providerModelFetchError[.ollama] = nil
|
||
if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
|
||
statusMessage = "Ollama отключена."
|
||
}
|
||
.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("Подключено")
|
||
.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 ? "Подключение…" : "Подключить") {
|
||
Task { await connectLocal(provider: .lmstudio) }
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.disabled(connectingLMStudio)
|
||
} else {
|
||
Text(state.lmstudioServerURL)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Button("Отключить") {
|
||
state.lmstudioServerURL = ""
|
||
lmstudioURL = ""
|
||
state.fetchedProviderModels[.lmstudio] = nil
|
||
state.providerModelFetchError[.lmstudio] = nil
|
||
if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
|
||
statusMessage = "LM Studio отключена."
|
||
}
|
||
.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-ключ (re_…)", text: $resendKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
TextField("Адрес отправителя (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("URL инстанса (https://…)", text: $n8nUrl)
|
||
.textFieldStyle(.roundedBorder)
|
||
SecureField("API-ключ", text: $n8nKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
IntegrationFilterRow(
|
||
label: "Воркфлоу",
|
||
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("Токен", text: $vercelToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
IntegrationFilterRow(
|
||
label: "Проекты",
|
||
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("Персональный токен доступа", text: $githubToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
Text("Классический токен с правом repo или fine-grained с доступом на чтение к Pull requests, Commit statuses и 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("Секретный ключ (sk_live_… или 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-ключ (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("Токен интеграции (secret_…)", text: $notionKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
Button("Сохранить интеграции") { saveIntegrations() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
.padding(6)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func hookResultText(_ key: String) -> some View {
|
||
if let text = hookResult[key] {
|
||
Text(text)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(.green)
|
||
}
|
||
}
|
||
|
||
// 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 = "❌ Автозапуск: \(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 = "Выберите папку .claude (⇧⌘. — показать скрытые файлы)"
|
||
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 = "❌ Выберите папку .claude (скрытая, в домашней папке)."
|
||
return nil
|
||
}
|
||
return url
|
||
}
|
||
|
||
private func installHooksAppStore() {
|
||
guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return }
|
||
let alert = NSAlert()
|
||
alert.messageText = "Установить хуки BroV в ~/.claude?"
|
||
alert.informativeText = "Будут записаны:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (сначала создаётся резервная копия)"
|
||
alert.addButton(withTitle: "Установить")
|
||
alert.addButton(withTitle: "Отмена")
|
||
alert.alertStyle = .informational
|
||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||
do {
|
||
try HookServer.shared.installAndWriteClaudeHooksAppStore(claudeURL: claudeURL)
|
||
hookNeedsUpdate = false
|
||
hookResult["claude"] = "✓ Хуки установлены"
|
||
statusMessage = "✓ Хуки установлены — перезапустите VS Code."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func uninstallHooksAppStore() {
|
||
guard let claudeURL = pickClaudeFolder(prompt: "Выбрать") else { return }
|
||
do {
|
||
try HookServer.shared.uninstallClaudeHooksAppStore(claudeURL: claudeURL)
|
||
hookResult["claude"] = "✓ Хуки удалены"
|
||
statusMessage = "✓ Хуки удалены."
|
||
} 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 = "Поддерживаются только адреса http:// и https://."
|
||
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 = "Моделей пока нет — сначала скачайте модель в \(name)."
|
||
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 = "✓ Подключено · моделей: \(models.count)"
|
||
case .failure:
|
||
statusMessage = "Не удалось подключиться к \(name) по адресу \(normalised). Он запущен?"
|
||
}
|
||
}
|
||
|
||
private func installHooks() {
|
||
do {
|
||
pendingHookJSON = try HookServer.shared.previewClaudeHooks()
|
||
hookResult["claude"] = nil
|
||
showDiff = true
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func confirmInstall() {
|
||
do {
|
||
try HookServer.shared.writeClaudeHooks()
|
||
showDiff = false
|
||
statusMessage = "✓ Хуки установлены в ~/.claude/settings.json"
|
||
pendingHookJSON = ""
|
||
hookNeedsUpdate = false
|
||
hookResult["claude"] = "✓ Хуки установлены"
|
||
} catch {
|
||
hookResult["claude"] = nil
|
||
statusMessage = "❌ Ошибка записи: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func uninstallHooks() {
|
||
do {
|
||
try HookServer.shared.uninstallClaudeHooks()
|
||
hookResult["claude"] = "✓ Хуки удалены"
|
||
statusMessage = "✓ Хуки удалены."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
#if !APPSTORE
|
||
private func triggerGeminiPreview(install: Bool) {
|
||
do {
|
||
geminiPendingInstall = install
|
||
pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install)
|
||
showGeminiDiff = true
|
||
hookResult["gemini"] = nil
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} 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
|
||
hookResult["gemini"] = geminiPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
|
||
statusMessage = geminiPendingInstall
|
||
? "✓ Хуки Gemini CLI установлены в ~/.gemini/settings.json"
|
||
: "✓ Хуки Gemini CLI удалены."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func triggerAgyPreview(install: Bool) {
|
||
do {
|
||
agyPendingInstall = install
|
||
pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install)
|
||
showAgyDiff = true
|
||
hookResult["agy"] = nil
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} 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
|
||
hookResult["agy"] = agyPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
|
||
statusMessage = agyPendingInstall
|
||
? "✓ Хуки Antigravity установлены в ~/.gemini/config/hooks.json"
|
||
: "✓ Хуки Antigravity удалены."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func triggerCodexPreview(install: Bool) {
|
||
do {
|
||
codexPendingInstall = install
|
||
pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install)
|
||
showCodexDiff = true
|
||
hookResult["codex"] = nil
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} 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
|
||
hookResult["codex"] = codexPendingInstall ? "✓ Хуки установлены" : "✓ Хуки удалены"
|
||
statusMessage = codexPendingInstall
|
||
? "✓ Хуки Codex установлены — выполните /hooks в Codex или откройте Hooks в настройках, чтобы доверить им."
|
||
: "✓ Хуки Codex удалены."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func installStatusLine() {
|
||
do {
|
||
pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true)
|
||
showStatusLineDiff = true
|
||
hookResult["statusline"] = nil
|
||
statusLinePendingInstall = true
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func uninstallStatusLine() {
|
||
do {
|
||
pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false)
|
||
showStatusLineDiff = true
|
||
hookResult["statusline"] = nil
|
||
statusLinePendingInstall = false
|
||
statusMessage = "Проверьте JSON ниже перед подтверждением."
|
||
} 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
|
||
}
|
||
hookResult["statusline"] = statusLinePendingInstall ? "✓ Ретранслятор установлен" : "✓ Ретранслятор удалён"
|
||
statusMessage = statusLinePendingInstall
|
||
? "✓ Строка состояния установлена."
|
||
: "✓ Строка состояния удалена."
|
||
} 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 = "✓ Ключи интеграций сохранены."
|
||
}
|
||
|
||
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 = "❌ Сначала сохраните токен Vercel."
|
||
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 = "❌ Проекты Vercel не найдены." }
|
||
}
|
||
}.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 = "❌ Сначала сохраните URL и API-ключ n8n."
|
||
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 = "❌ Воркфлоу n8n не найдены." }
|
||
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 = "❌ Воркфлоу n8n не найдены." }
|
||
}
|
||
}.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 "Скоро" }
|
||
#if !APPSTORE
|
||
if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Хуки не установлены" }
|
||
if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Хуки не установлены" }
|
||
if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Хуки не установлены" }
|
||
#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 "Не подключено" }
|
||
} 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 "Ключ не настроен" }
|
||
}
|
||
}
|
||
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("Основная")
|
||
.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 ? "Загрузить список" : "Обновить") { onLoad() }
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.mini)
|
||
}
|
||
if !filter.isEmpty {
|
||
Button("Сбросить") { 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("Отслеживается \(filter.count) из \(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 ? "Нажмите клавиши…" : 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 ? "Нет" : 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] ?? "·"
|
||
}
|
||
}
|