From 9570dc190fabac0042205acba512311fed1f6930 Mon Sep 17 00:00:00 2001 From: maksarsanjeev Date: Wed, 7 Oct 2026 15:45:38 +0300 Subject: [PATCH] Func pill: eyedropper (colour in many formats in a new brov-chat tab, HEX to clipboard, context for claude) and live Mac card (CPU, cores, RAM, SSD) Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Sources/App/AppGridView.swift | 8 +- NotchBuddy/Sources/App/ClaudeService.swift | 3 + NotchBuddy/Sources/App/Func.swift | 396 +++++++++++++++++++++ NotchBuddy/Sources/App/Tabs.swift | 10 + 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 NotchBuddy/Sources/App/Func.swift diff --git a/NotchBuddy/Sources/App/AppGridView.swift b/NotchBuddy/Sources/App/AppGridView.swift index 3a39e28..04f9bfc 100644 --- a/NotchBuddy/Sources/App/AppGridView.swift +++ b/NotchBuddy/Sources/App/AppGridView.swift @@ -27,6 +27,7 @@ enum DesktopApps { struct AppGridView: View { @ObservedObject var state: AppState + @State private var showSystem = false /// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit. private var claudeLimit: PillLimit? { @@ -85,7 +86,7 @@ struct AppGridView: View { VStack(spacing: 8) { HStack(spacing: 8) { ShelfPill() - EmptyPill() + FuncPill(state: state, showSystem: $showSystem) } HStack(spacing: 8) { AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude)) @@ -96,6 +97,11 @@ struct AppGridView: View { .padding(.trailing, 20) .padding(.vertical, 6) + if showSystem { + SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } } + .transition(.opacity) + } + #if !APPSTORE if state.showingPlanDetail { CardBackground(wash: nil) diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 578577d..60489e9 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -275,6 +275,7 @@ final class ClaudeService { let tab = state.activeTabId let cli = ChatTabs.shared.cli(for: tab) func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab } + let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil var prompt = query // Context goes with the first message of a conversation, like the API chat. if cli.sessionID == nil, let context { @@ -293,6 +294,8 @@ final class ClaudeService { } } + if let seed { prompt = seed + "\n\n" + prompt } + let placeholder = ChatMessage(role: .assistant, content: "") let msgId = placeholder.id if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) } diff --git a/NotchBuddy/Sources/App/Func.swift b/NotchBuddy/Sources/App/Func.swift new file mode 100644 index 0000000..734f7e9 --- /dev/null +++ b/NotchBuddy/Sources/App/Func.swift @@ -0,0 +1,396 @@ +import SwiftUI +import AppKit +import Darwin + +// MARK: - Func pill (top-right slot of the home view) +// +// Small tools, one icon each: +// • Пипетка — pick any pixel on screen; a new brov-chat tab explains the colour in many +// formats (computed locally, instant) and the HEX goes to the clipboard. The colour is +// passed to claude as context for follow-up questions in that tab. +// • Мак — live card with CPU / cores / RAM / SSD of this Mac. + +struct FuncPill: View { + @ObservedObject var state: AppState + @Binding var showSystem: Bool + + var body: some View { + HStack(spacing: 6) { + Text("Func") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(Color(hex: "#8E939C")) + .padding(.leading, 12) + Spacer(minLength: 4) + FuncButton(symbol: "eyedropper", title: "Пипетка") { ColorPicking.start(state: state) } + FuncButton(symbol: "cpu", title: "Мак") { + withAnimation(.easeOut(duration: 0.18)) { showSystem.toggle() } + } + } + .padding(.trailing, 6) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(Color(hex: "#0E0F11"))) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.white.opacity(0.10), lineWidth: 1)) + } +} + +struct FuncButton: View { + let symbol: String + let title: String + let action: () -> Void + @State private var hovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: symbol).font(.system(size: 11, weight: .semibold)) + Text(title).font(.system(size: 11, weight: .medium)) + } + .foregroundColor(Color(hex: hovered ? "#F5F6F8" : "#B0B5BE")) + .padding(.horizontal, 9) + .frame(height: 26) + .background(Capsule().fill(Color.white.opacity(hovered ? 0.12 : 0.06))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovered = $0 } + } +} + +// MARK: - Eyedropper + +@MainActor +enum ColorPicking { + private static var sampler: NSColorSampler? + + static func start(state: AppState) { + SoundEngine.shared.play("blip") + let s = NSColorSampler() + sampler = s + s.show { picked in + Task { @MainActor in + sampler = nil + guard let picked, let c = picked.usingColorSpace(.sRGB) else { return } + deliver(ColorFormats(c), state: state) + } + } + } + + private static func deliver(_ f: ColorFormats, state: AppState) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(f.hex, forType: .string) + + // A fresh brov-chat tab (or the current chat one when all 6 tabs are taken). + if state.tabs.count < AppState.maxTabs { + state.openTab(.chat) + } else if let chat = state.tabs.first(where: { $0.kind == .chat }) { + state.activateTab(chat.id) + } + if let tab = state.activeTabId { + ChatTabs.shared.seed(tab, "Пользователь выбрал пипеткой цвет \(f.hex) (rgb \(f.rgbTriple)). Вопросы ниже — про этот цвет.") + } + state.chatHistory.append(ChatMessage(role: .assistant, content: f.markdown)) + NotificationCenter.default.post(name: .hookExpand, object: IslandView.prompt) + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) + } +} + +struct ColorFormats { + let r: Double, g: Double, b: Double // 0…1 sRGB + + init(_ c: NSColor) { + r = Double(c.redComponent); g = Double(c.greenComponent); b = Double(c.blueComponent) + } + + var R: Int { Int((r * 255).rounded()) } + var G: Int { Int((g * 255).rounded()) } + var B: Int { Int((b * 255).rounded()) } + var hex: String { String(format: "#%02X%02X%02X", R, G, B) } + var rgbTriple: String { "\(R), \(G), \(B)" } + + var hsl: (h: Int, s: Int, l: Int) { + let mx = max(r, g, b), mn = min(r, g, b), l = (mx + mn) / 2 + guard mx != mn else { return (0, 0, Int((l * 100).rounded())) } + let d = mx - mn + let s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn) + return (hue(mx: mx, d: d), Int((s * 100).rounded()), Int((l * 100).rounded())) + } + + var hsb: (h: Int, s: Int, b: Int) { + let mx = max(r, g, b), mn = min(r, g, b), d = mx - mn + let s = mx == 0 ? 0 : d / mx + return (d == 0 ? 0 : hue(mx: mx, d: d), Int((s * 100).rounded()), Int((mx * 100).rounded())) + } + + var cmyk: (c: Int, m: Int, y: Int, k: Int) { + let k = 1 - max(r, g, b) + guard k < 1 else { return (0, 0, 0, 100) } + func p(_ v: Double) -> Int { Int(((1 - v - k) / (1 - k) * 100).rounded()) } + return (p(r), p(g), p(b), Int((k * 100).rounded())) + } + + private func hue(mx: Double, d: Double) -> Int { + var h: Double + if mx == r { h = (g - b) / d + (g < b ? 6 : 0) } + else if mx == g { h = (b - r) / d + 2 } + else { h = (r - g) / d + 4 } + h *= 60 + return Int(h.rounded()) % 360 + } + + /// Relative luminance → which text colour reads on top of it. + var luminance: Double { + func lin(_ v: Double) -> Double { v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) } + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + } + var contrastWhite: Double { 1.05 / (luminance + 0.05) } + var contrastBlack: Double { (luminance + 0.05) / 0.05 } + + var nearestName: (en: String, ru: String) { + ColorNames.nearest(R, G, B) + } + + var markdown: String { + let (h1, s1, l1) = hsl, (h2, s2, b2) = hsb, (c, m, y, k) = cmyk + let name = nearestName + let text = contrastWhite >= contrastBlack ? "белый" : "чёрный" + return """ + **\(hex)** — \(name.ru) (ближе всего к `\(name.en)`). HEX уже в буфере обмена. + + - **HEX:** `\(hex)` · без решётки `\(hex.dropFirst())` · с альфой `\(hex)FF` + - **RGB:** `rgb(\(R), \(G), \(B))` · дроби `\(String(format: "%.3f, %.3f, %.3f", r, g, b))` + - **HSL:** `hsl(\(h1), \(s1)%, \(l1)%)` + - **HSB/HSV:** `\(h2)°, \(s2)%, \(b2)%` + - **CMYK:** `\(c)%, \(m)%, \(y)%, \(k)%` + - **SwiftUI:** `Color(red: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)))` + - **NSColor/UIColor:** `NSColor(srgbRed: \(String(format: "%.3f", r)), green: \(String(format: "%.3f", g)), blue: \(String(format: "%.3f", b)), alpha: 1)` + - **Android:** `0xFF\(hex.dropFirst())` · **0x:** `0x\(hex.dropFirst())` + - **Контраст:** с белым \(String(format: "%.1f", contrastWhite)):1, с чёрным \(String(format: "%.1f", contrastBlack)):1 — текст лучше делать \(text) + """ + } +} + +/// CSS named colours (subset is enough to give a recognisable name). +enum ColorNames { + static let table: [(String, String, Int, Int, Int)] = [ + ("black", "чёрный", 0, 0, 0), ("white", "белый", 255, 255, 255), ("gray", "серый", 128, 128, 128), + ("silver", "серебристый", 192, 192, 192), ("dimgray", "тёмно-серый", 105, 105, 105), + ("gainsboro", "светло-серый", 220, 220, 220), ("red", "красный", 255, 0, 0), + ("darkred", "тёмно-красный", 139, 0, 0), ("crimson", "малиновый", 220, 20, 60), + ("firebrick", "кирпичный", 178, 34, 34), ("salmon", "лососевый", 250, 128, 114), + ("tomato", "томатный", 255, 99, 71), ("coral", "коралловый", 255, 127, 80), + ("orangered", "красно-оранжевый", 255, 69, 0), ("orange", "оранжевый", 255, 165, 0), + ("darkorange", "тёмно-оранжевый", 255, 140, 0), ("gold", "золотой", 255, 215, 0), + ("yellow", "жёлтый", 255, 255, 0), ("khaki", "хаки", 240, 230, 140), + ("beige", "бежевый", 245, 245, 220), ("wheat", "пшеничный", 245, 222, 179), + ("tan", "песочный", 210, 180, 140), ("peru", "перу (коричнево-рыжий)", 205, 133, 63), + ("chocolate", "шоколадный", 210, 105, 30), ("sienna", "сиена", 160, 82, 45), + ("saddlebrown", "коричневый", 139, 69, 19), ("maroon", "бордовый", 128, 0, 0), + ("olive", "оливковый", 128, 128, 0), ("yellowgreen", "жёлто-зелёный", 154, 205, 50), + ("lime", "лаймовый", 0, 255, 0), ("limegreen", "лаймово-зелёный", 50, 205, 50), + ("green", "зелёный", 0, 128, 0), ("darkgreen", "тёмно-зелёный", 0, 100, 0), + ("forestgreen", "лесной зелёный", 34, 139, 34), ("seagreen", "морской зелёный", 46, 139, 87), + ("mediumseagreen", "изумрудный", 60, 179, 113), ("springgreen", "весенний зелёный", 0, 255, 127), + ("teal", "бирюзово-зелёный (teal)", 0, 128, 128), ("turquoise", "бирюзовый", 64, 224, 208), + ("cyan", "голубой (циан)", 0, 255, 255), ("lightblue", "светло-голубой", 173, 216, 230), + ("skyblue", "небесный", 135, 206, 235), ("deepskyblue", "ярко-голубой", 0, 191, 255), + ("dodgerblue", "синий доджер", 30, 144, 255), ("steelblue", "стальной синий", 70, 130, 180), + ("royalblue", "королевский синий", 65, 105, 225), ("blue", "синий", 0, 0, 255), + ("mediumblue", "средне-синий", 0, 0, 205), ("navy", "тёмно-синий (navy)", 0, 0, 128), + ("midnightblue", "полуночно-синий", 25, 25, 112), ("slateblue", "сланцево-синий", 106, 90, 205), + ("indigo", "индиго", 75, 0, 130), ("purple", "фиолетовый", 128, 0, 128), + ("darkviolet", "тёмно-фиолетовый", 148, 0, 211), ("mediumpurple", "лавандово-фиолетовый", 147, 112, 219), + ("orchid", "орхидея", 218, 112, 214), ("violet", "фиалковый", 238, 130, 238), + ("plum", "сливовый", 221, 160, 221), ("lavender", "лавандовый", 230, 230, 250), + ("magenta", "пурпурный (маджента)", 255, 0, 255), ("deeppink", "ярко-розовый", 255, 20, 147), + ("hotpink", "горячий розовый", 255, 105, 180), ("pink", "розовый", 255, 192, 203), + ("lightpink", "светло-розовый", 255, 182, 193), ("mistyrose", "туманно-розовый", 255, 228, 225), + ("ivory", "слоновая кость", 255, 255, 240), ("linen", "льняной", 250, 240, 230), + ("slategray", "сланцево-серый", 112, 128, 144), ("darkslategray", "тёмный сланец", 47, 79, 79), + ("cadetblue", "кадетский синий", 95, 158, 160), ("aquamarine", "аквамарин", 127, 255, 212), + ] + + static func nearest(_ r: Int, _ g: Int, _ b: Int) -> (en: String, ru: String) { + // Weighted RGB distance ("redmean"), good enough for naming. + var best = table[0], bestD = Double.infinity + for c in table { + let rm = Double(r + c.2) / 2 + let dr = Double(r - c.2), dg = Double(g - c.3), db = Double(b - c.4) + let d = (2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db + if d < bestD { bestD = d; best = c } + } + return (best.0, best.1) + } +} + +// MARK: - System info + +struct SystemSnapshot { + var chip = "" + var model = "" + var pCores = 0, eCores = 0, logical = 0 + var cpuLoad: Double = 0 // 0…1 overall + var ramTotal: UInt64 = 0, ramUsed: UInt64 = 0 + var diskTotal: Int64 = 0, diskFree: Int64 = 0 + var uptime: TimeInterval = 0 + var osVersion = "" +} + +@MainActor +final class SystemMonitor: ObservableObject { + static let shared = SystemMonitor() + @Published var snap = SystemSnapshot() + private var timer: Timer? + private var prevTicks: (user: UInt64, sys: UInt64, idle: UInt64, nice: UInt64)? + + func start() { + guard timer == nil else { return } + snap = Self.staticInfo() + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + Task { @MainActor in SystemMonitor.shared.refresh() } + } + } + + func stop() { timer?.invalidate(); timer = nil; prevTicks = nil } + + private func refresh() { + var s = snap + s.cpuLoad = cpuLoad() ?? s.cpuLoad + (s.ramTotal, s.ramUsed) = Self.memory() + if let v = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey]) { + s.diskTotal = Int64(v.volumeTotalCapacity ?? 0) + s.diskFree = v.volumeAvailableCapacityForImportantUsage ?? 0 + } + s.uptime = ProcessInfo.processInfo.systemUptime + snap = s + } + + private static func sysctlString(_ name: String) -> String { + var size = 0 + sysctlbyname(name, nil, &size, nil, 0) + guard size > 0 else { return "" } + var buf = [CChar](repeating: 0, count: size) + sysctlbyname(name, &buf, &size, nil, 0) + return String(cString: buf) + } + + private static func sysctlInt(_ name: String) -> Int { + var v: Int64 = 0 + var size = MemoryLayout.size + return sysctlbyname(name, &v, &size, nil, 0) == 0 ? Int(v) : 0 + } + + private static func staticInfo() -> SystemSnapshot { + var s = SystemSnapshot() + s.chip = sysctlString("machdep.cpu.brand_string") + s.model = sysctlString("hw.model") + s.pCores = sysctlInt("hw.perflevel0.physicalcpu") + s.eCores = sysctlInt("hw.perflevel1.physicalcpu") + s.logical = sysctlInt("hw.logicalcpu") + let v = ProcessInfo.processInfo.operatingSystemVersion + s.osVersion = "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + return s + } + + /// Used = what Activity Monitor calls "Memory Used": app (active+inactive−purgeable… ≈ internal) + wired + compressed. + private static func memory() -> (UInt64, UInt64) { + let total = ProcessInfo.processInfo.physicalMemory + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let kr = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard kr == KERN_SUCCESS else { return (total, 0) } + let page = UInt64(getpagesize()) + let app = UInt64(stats.internal_page_count) - UInt64(stats.purgeable_count) + let used = (app + UInt64(stats.wire_count) + UInt64(stats.compressor_page_count)) * page + return (total, min(total, used)) + } + + private func cpuLoad() -> Double? { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let kr = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard kr == KERN_SUCCESS else { return nil } + let t = (user: UInt64(info.cpu_ticks.0), sys: UInt64(info.cpu_ticks.1), + idle: UInt64(info.cpu_ticks.2), nice: UInt64(info.cpu_ticks.3)) + defer { prevTicks = t } + guard let p = prevTicks else { return nil } + let busy = Double((t.user - p.user) + (t.sys - p.sys) + (t.nice - p.nice)) + let all = busy + Double(t.idle - p.idle) + return all > 0 ? busy / all : nil + } +} + +struct SystemCard: View { + @ObservedObject private var mon = SystemMonitor.shared + let onClose: () -> Void + + private func gb(_ b: Int64) -> String { String(format: "%.0f ГБ", Double(b) / 1_000_000_000) } + private func gib(_ b: UInt64) -> String { String(format: "%.1f ГБ", Double(b) / 1_073_741_824) } + + var body: some View { + let s = mon.snap + let diskUsed = max(0, s.diskTotal - s.diskFree) + ZStack(alignment: .topTrailing) { + CardBackground(wash: nil) + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Text(s.chip.isEmpty ? "Mac" : s.chip).font(.system(size: 12.5, weight: .semibold)) + Text("· \(s.model) · \(s.osVersion)").font(.system(size: 10.5)).foregroundColor(Color(hex: "#8E939C")) + } + SysRow(title: "CPU", value: "\(Int((s.cpuLoad * 100).rounded()))% · ядер \(s.pCores) произв. + \(s.eCores) эфф. (\(s.logical) потоков)", + fraction: s.cpuLoad) + SysRow(title: "ОЗУ", value: "занято \(gib(s.ramUsed)) из \(gib(s.ramTotal)) · свободно \(gib(s.ramTotal - s.ramUsed))", + fraction: s.ramTotal > 0 ? Double(s.ramUsed) / Double(s.ramTotal) : 0) + SysRow(title: "SSD", value: "занято \(gb(diskUsed)) из \(gb(s.diskTotal)) · свободно \(gb(s.diskFree))", + fraction: s.diskTotal > 0 ? Double(diskUsed) / Double(s.diskTotal) : 0) + } + .padding(.leading, 104) + .padding(.trailing, 36) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + + Button(action: onClose) { + Image(systemName: "xmark").font(.system(size: 8, weight: .bold)) + .foregroundColor(Color(hex: "#8E939C")) + .frame(width: 18, height: 18) + .background(Circle().fill(Color.white.opacity(0.08))) + } + .buttonStyle(.plain) + .padding(10) + } + .onAppear { SystemMonitor.shared.start() } + .onDisappear { SystemMonitor.shared.stop() } + } +} + +private struct SysRow: View { + let title: String + let value: String + let fraction: Double + + var body: some View { + HStack(spacing: 8) { + Text(title).font(.system(size: 10.5, weight: .bold, design: .monospaced)) + .foregroundColor(Color(hex: "#8E939C")).frame(width: 30, alignment: .leading) + VStack(alignment: .leading, spacing: 3) { + Text(value).font(.system(size: 11)).lineLimit(1).minimumScaleFactor(0.8) + GeometryReader { g in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.08)) + Capsule().fill(Color(hex: ClaudePlanGauge.color(for: fraction * 100))) + .frame(width: max(3, g.size.width * min(1, fraction))) + } + } + .frame(height: 4) + } + } + } +} diff --git a/NotchBuddy/Sources/App/Tabs.swift b/NotchBuddy/Sources/App/Tabs.swift index c78dee5..961ae18 100644 --- a/NotchBuddy/Sources/App/Tabs.swift +++ b/NotchBuddy/Sources/App/Tabs.swift @@ -45,6 +45,15 @@ final class ChatTabs { return c } + private var seeds: [UUID: String] = [:] + + /// Context prepended to the first message sent from a tab (e.g. the picked colour). + func seed(_ id: UUID, _ text: String) { seeds[id] = text } + func takeSeed(_ id: UUID?) -> String? { + guard let id else { return nil } + return seeds.removeValue(forKey: id) + } + func saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h } func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] } @@ -73,6 +82,7 @@ final class ChatTabs { clis[id]?.cancel() clis.removeValue(forKey: id) histories.removeValue(forKey: id) + seeds.removeValue(forKey: id) } }