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 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ enum DesktopApps {
|
|||||||
|
|
||||||
struct AppGridView: View {
|
struct AppGridView: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
@State private var showSystem = false
|
||||||
|
|
||||||
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
|
/// "9% · сброс через 1 д 18 ч" — the Claude window closest to its limit.
|
||||||
private var claudeLimit: PillLimit? {
|
private var claudeLimit: PillLimit? {
|
||||||
@@ -85,7 +86,7 @@ struct AppGridView: View {
|
|||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ShelfPill()
|
ShelfPill()
|
||||||
EmptyPill()
|
FuncPill(state: state, showSystem: $showSystem)
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
|
AppPill(app: DesktopApps.claude, limit: claudeLimit, alert: alert(for: .claude))
|
||||||
@@ -96,6 +97,11 @@ struct AppGridView: View {
|
|||||||
.padding(.trailing, 20)
|
.padding(.trailing, 20)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
if showSystem {
|
||||||
|
SystemCard { withAnimation(.easeOut(duration: 0.18)) { showSystem = false } }
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
#if !APPSTORE
|
#if !APPSTORE
|
||||||
if state.showingPlanDetail {
|
if state.showingPlanDetail {
|
||||||
CardBackground(wash: nil)
|
CardBackground(wash: nil)
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ final class ClaudeService {
|
|||||||
let tab = state.activeTabId
|
let tab = state.activeTabId
|
||||||
let cli = ChatTabs.shared.cli(for: tab)
|
let cli = ChatTabs.shared.cli(for: tab)
|
||||||
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
|
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
|
||||||
|
let seed = cli.sessionID == nil ? ChatTabs.shared.takeSeed(tab) : nil
|
||||||
var prompt = query
|
var prompt = query
|
||||||
// Context goes with the first message of a conversation, like the API chat.
|
// Context goes with the first message of a conversation, like the API chat.
|
||||||
if cli.sessionID == nil, let context {
|
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 placeholder = ChatMessage(role: .assistant, content: "")
|
||||||
let msgId = placeholder.id
|
let msgId = placeholder.id
|
||||||
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
|
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
|
||||||
|
|||||||
@@ -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<Int64>.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<vm_statistics64>.size / MemoryLayout<integer_t>.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<host_cpu_load_info>.size / MemoryLayout<integer_t>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ final class ChatTabs {
|
|||||||
return c
|
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 saveHistory(_ h: [ChatMessage], for id: UUID) { histories[id] = h }
|
||||||
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
|
func history(for id: UUID) -> [ChatMessage] { histories[id] ?? [] }
|
||||||
|
|
||||||
@@ -73,6 +82,7 @@ final class ChatTabs {
|
|||||||
clis[id]?.cancel()
|
clis[id]?.cancel()
|
||||||
clis.removeValue(forKey: id)
|
clis.removeValue(forKey: id)
|
||||||
histories.removeValue(forKey: id)
|
histories.removeValue(forKey: id)
|
||||||
|
seeds.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user