mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-26 16:58:28 +00:00
* Mark estimated costs with the same ~ in every view The ~ marker and legend that report already used now appear wherever a dollar figure is mostly a guess: the models and sessions CLI output, the desktop Models, Overview, Sessions and Settings provider rows, the macOS menu bar model rows and provider tabs, and the Windows tray. One rule decides the marker everywhere: the estimated portion is at least 1% of the figure. report, overview and the MCP tables switch to it from "any estimated cent", so a metered Codex row holding a few text-estimated turns stays unmarked. JSON is additive: estimatedCostUSD and isEstimated on models rows, estimatedCost and isEstimated on sessions rows, estimatedCostUSD on menubar providerDetails rows. Totals are unchanged. * Skip the ~ marker on figures that print as zero A sub-cent row that is fully estimated printed as "~$0.00": nothing visible to qualify. The shared helper now also requires the amount, as the surface prints it in the active currency, to show a non-zero digit. CLI, desktop, macOS and Windows helpers all apply it.
535 lines
23 KiB
Swift
535 lines
23 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Shared state read by the NSEvent local monitor closure. The closure
|
|
/// snapshots its captured environment at install time, so SwiftUI @State
|
|
/// can't be used directly — a reference-type holder keeps the latest hover
|
|
/// status visible to the monitor across SwiftUI updates.
|
|
@MainActor
|
|
final class AgentTabStripScrollState {
|
|
static let shared = AgentTabStripScrollState()
|
|
var isStripHovered: Bool = false
|
|
}
|
|
|
|
struct AgentTabStrip: View {
|
|
@Environment(AppStore.self) private var store
|
|
@State private var stripViewportWidth: CGFloat = 0
|
|
@State private var stripContentWidth: CGFloat = 0
|
|
@State private var scrollWheelMonitor: Any?
|
|
@State private var viewportAnchor: ProviderFilter?
|
|
|
|
var body: some View {
|
|
GeometryReader { viewportGeo in
|
|
ScrollViewReader { proxy in
|
|
HStack(spacing: 4) {
|
|
if isOverflowing {
|
|
Button {
|
|
pageProviderStrip(direction: .backward, proxy: proxy)
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35))
|
|
.disabled(!canMoveBackward)
|
|
.help(L("Show previous providers"))
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 5) {
|
|
ForEach(visibleFilters) { filter in
|
|
AgentTab(
|
|
filter: filter,
|
|
cost: cost(for: filter),
|
|
isEstimated: isEstimated(filter),
|
|
isActive: store.selectedProvider == filter,
|
|
quota: store.quotaSummary(for: filter)
|
|
) {
|
|
viewportAnchor = filter
|
|
store.switchTo(provider: filter)
|
|
withAnimation(.easeInOut(duration: 0.18)) {
|
|
proxy.scrollTo(filter.id, anchor: .center)
|
|
}
|
|
}
|
|
.id(filter.id)
|
|
}
|
|
}
|
|
.background(
|
|
GeometryReader { contentGeo in
|
|
Color.clear
|
|
.onAppear {
|
|
stripContentWidth = contentGeo.size.width
|
|
}
|
|
.onChange(of: contentGeo.size.width) { _, newWidth in
|
|
stripContentWidth = newWidth
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
.onHover { hovering in
|
|
AgentTabStripScrollState.shared.isStripHovered = hovering
|
|
}
|
|
|
|
if isOverflowing {
|
|
Button {
|
|
pageProviderStrip(direction: .forward, proxy: proxy)
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35))
|
|
.disabled(!canMoveForward)
|
|
.help(L("Show next providers"))
|
|
}
|
|
}
|
|
.onAppear {
|
|
stripViewportWidth = viewportGeo.size.width
|
|
installScrollWheelMonitorIfNeeded()
|
|
viewportAnchor = pagingState.viewportAnchor
|
|
scrollToViewportAnchor(proxy: proxy)
|
|
}
|
|
.onChange(of: viewportGeo.size.width) { _, newWidth in
|
|
stripViewportWidth = newWidth
|
|
}
|
|
.onChange(of: store.selectedProvider) { _, newProvider in
|
|
viewportAnchor = visibleFilters.contains(newProvider) ? newProvider : pagingState.viewportAnchor
|
|
scrollToViewportAnchor(proxy: proxy)
|
|
}
|
|
.onChange(of: visibleFilters) { _, _ in
|
|
viewportAnchor = pagingState.viewportAnchor
|
|
scrollToViewportAnchor(proxy: proxy)
|
|
}
|
|
.onDisappear {
|
|
removeScrollWheelMonitorIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 38)
|
|
}
|
|
|
|
private var periodAll: MenubarPayload {
|
|
// `store.payload` is the SCOPED selection. Falling back to it while the
|
|
// selection has no cached data of its own used to surface the previous
|
|
// provider's totals here; the same hasCachedData gate the popover body
|
|
// uses keeps that out of the tab strip.
|
|
store.periodAllPayload ?? (store.hasCachedData ? store.payload : .empty)
|
|
}
|
|
|
|
private var visibleFilters: [ProviderFilter] {
|
|
// Tabs reflect the selected range. The payload's activity signal keeps
|
|
// token-only and subscription/flat-rate providers visible while hiding
|
|
// providers merely discovered on disk. Older payloads lack the activity
|
|
// signal, so their zero-cost discovery rows fail closed.
|
|
let activeKeys = ProviderVisibility.activeKeys(
|
|
providerDetails: periodAll.current.providerDetails,
|
|
legacyProviders: periodAll.current.providers
|
|
)
|
|
let costs = Dictionary(uniqueKeysWithValues: ProviderFilter.allCases.map { ($0, cost(for: $0) ?? 0) })
|
|
let detected = ProviderFilter.allCases.filter { filter in
|
|
filter == .all
|
|
|| activeKeys.contains(filter.cliArg)
|
|
|| filter.providerKeys.contains(where: activeKeys.contains)
|
|
}
|
|
return detected.sorted { a, b in
|
|
if a == .all { return true }
|
|
if b == .all { return false }
|
|
let ca = costs[a, default: 0]
|
|
let cb = costs[b, default: 0]
|
|
if ca != cb { return ca > cb }
|
|
return a.rawValue < b.rawValue
|
|
}
|
|
}
|
|
|
|
private func isEstimated(_ filter: ProviderFilter) -> Bool {
|
|
guard filter != .all, let cost = cost(for: filter) else { return false }
|
|
let estimated = periodAll.current.providerDetails
|
|
.filter { filter.providerKeys.contains($0.id.lowercased()) || filter.providerKeys.contains($0.label.lowercased()) }
|
|
.reduce(0.0) { $0 + ($1.estimatedCostUSD ?? 0) }
|
|
return isEstimatedCost(cost, estimated, shown: cost.asCompactCurrency())
|
|
}
|
|
|
|
private func cost(for filter: ProviderFilter) -> Double? {
|
|
let data = periodAll
|
|
if filter == .all { return data.current.cost }
|
|
let providers = Dictionary(
|
|
data.current.providers.map { ($0.key.lowercased(), $0.value) },
|
|
uniquingKeysWith: +
|
|
)
|
|
return filter.providerKeys.reduce(0.0) { sum, key in
|
|
sum + (providers[key] ?? 0)
|
|
}
|
|
}
|
|
|
|
private var pagingState: ProviderStripPagingState {
|
|
ProviderStripPagingState(
|
|
filters: visibleFilters,
|
|
selectedProvider: store.selectedProvider,
|
|
viewportAnchor: viewportAnchor
|
|
)
|
|
}
|
|
|
|
private var canMoveBackward: Bool { pagingState.canMoveBackward }
|
|
private var canMoveForward: Bool { pagingState.canMoveForward }
|
|
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
|
|
|
|
private func pageProviderStrip(direction: ProviderStripPagingDirection, proxy: ScrollViewProxy) {
|
|
var state = pagingState
|
|
guard let target = state.move(direction: direction) else { return }
|
|
viewportAnchor = target
|
|
scrollToViewportAnchor(proxy: proxy)
|
|
}
|
|
|
|
private func scrollToViewportAnchor(proxy: ScrollViewProxy) {
|
|
guard let viewportAnchor else { return }
|
|
withAnimation(.easeInOut(duration: 0.18)) {
|
|
proxy.scrollTo(viewportAnchor.id, anchor: .center)
|
|
}
|
|
}
|
|
|
|
/// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal
|
|
/// `ScrollView` ignores. While the cursor is over the strip we transpose
|
|
/// vertical-axis scroll fields onto the horizontal axis so the underlying
|
|
/// NSScrollView receives a real horizontal delta. Trackpad events (precise
|
|
/// deltas, with native horizontal component) are passed through untouched
|
|
/// so vertical scrolling elsewhere in the popover is unaffected.
|
|
private func installScrollWheelMonitorIfNeeded() {
|
|
guard scrollWheelMonitor == nil else { return }
|
|
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
|
guard AgentTabStripScrollState.shared.isStripHovered,
|
|
!event.hasPreciseScrollingDeltas,
|
|
abs(event.scrollingDeltaX) < 0.001,
|
|
abs(event.scrollingDeltaY) > 0,
|
|
let cg = event.cgEvent?.copy() else {
|
|
return event
|
|
}
|
|
let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1)
|
|
let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1)
|
|
let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1)
|
|
cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0)
|
|
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0)
|
|
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
|
|
cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY)
|
|
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY)
|
|
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY)
|
|
return NSEvent(cgEvent: cg) ?? event
|
|
}
|
|
}
|
|
|
|
private func removeScrollWheelMonitorIfNeeded() {
|
|
if let monitor = scrollWheelMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
scrollWheelMonitor = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AgentTab: View {
|
|
let filter: ProviderFilter
|
|
let cost: Double?
|
|
var isEstimated = false
|
|
let isActive: Bool
|
|
let quota: QuotaSummary?
|
|
let onTap: () -> Void
|
|
|
|
@State private var hoverPopoverShown = false
|
|
@State private var hoverEnterTask: DispatchWorkItem?
|
|
@State private var hoverExitTask: DispatchWorkItem?
|
|
@State private var clickDismissed = false
|
|
|
|
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
|
|
/// label, even when not yet connected. Driven by which providers we
|
|
/// actually implement live-quota fetching for in AppStore.quotaSummary.
|
|
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
|
|
switch filter {
|
|
case .claude, .codex: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
hoverPopoverShown = false
|
|
hoverEnterTask?.cancel()
|
|
clickDismissed = true
|
|
onTap()
|
|
} label: {
|
|
VStack(spacing: 3) {
|
|
HStack(spacing: 5) {
|
|
Text(filter.rawValue)
|
|
.font(.system(size: 11.5, weight: .medium))
|
|
.tracking(-0.05)
|
|
if let cost, cost > 0 {
|
|
Text((isEstimated ? "~" : "") + cost.asCompactCurrency())
|
|
.font(.codeMono(size: 10.5, weight: .medium))
|
|
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
|
.tracking(-0.2)
|
|
}
|
|
}
|
|
if quota != nil {
|
|
AgentTabQuotaBar(quota: quota, isActive: isActive)
|
|
.frame(height: 3)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
|
|
)
|
|
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.onHover { hovering in
|
|
hoverEnterTask?.cancel()
|
|
hoverExitTask?.cancel()
|
|
if !hovering {
|
|
clickDismissed = false
|
|
let task = DispatchWorkItem { hoverPopoverShown = false }
|
|
hoverExitTask = task
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
|
|
} else if !clickDismissed, quota != nil {
|
|
let task = DispatchWorkItem { hoverPopoverShown = true }
|
|
hoverEnterTask = task
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
|
|
}
|
|
}
|
|
.popover(isPresented: $hoverPopoverShown) {
|
|
if let quota {
|
|
QuotaDetailPopover(quota: quota)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
|
|
/// source. Width matches the chip; color shifts green → amber → red at 70% / 90%.
|
|
private struct AgentTabQuotaBar: View {
|
|
let quota: QuotaSummary?
|
|
let isActive: Bool
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(trackColor)
|
|
if let percent = filledFraction {
|
|
Capsule()
|
|
.fill(barColor)
|
|
.frame(width: max(2, geo.size.width * CGFloat(percent)))
|
|
.animation(.easeOut(duration: 0.25), value: percent)
|
|
}
|
|
if case .terminalFailure = quota?.connection {
|
|
// Hatched/red strip to telegraph "broken; reconnect needed".
|
|
Capsule()
|
|
.fill(Color.red.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filledFraction: Double? {
|
|
guard let pct = quota?.primary?.percent else { return nil }
|
|
return min(max(pct, 0), 1)
|
|
}
|
|
|
|
private var barColor: Color {
|
|
guard let pct = quota?.primary?.percent else { return .clear }
|
|
switch QuotaSummary.severity(for: pct) {
|
|
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
|
|
case .warning: return Color.yellow
|
|
case .critical: return Color.orange
|
|
case .danger: return Color.red
|
|
}
|
|
}
|
|
|
|
private var trackColor: Color {
|
|
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
|
|
}
|
|
}
|
|
|
|
private struct QuotaDetailPopover: View {
|
|
let quota: QuotaSummary
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
switch quota.connection {
|
|
case .terminalFailure(let reason):
|
|
terminalFailureCard(reason: reason)
|
|
case .disconnected:
|
|
Text(disconnectedMessage)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
case .loading where quota.details.isEmpty:
|
|
Text(L("Loading…"))
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
default:
|
|
rowsCard
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(width: 260)
|
|
}
|
|
|
|
private var disconnectedMessage: String {
|
|
switch quota.providerFilter {
|
|
case .codex: return L("Sign in with `codex` (ChatGPT mode) to track quota.")
|
|
case .claude: return L("Sign in to Claude Code to track quota.")
|
|
default: return L("Sign in to track quota.")
|
|
}
|
|
}
|
|
|
|
private var rowsCard: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text(L("%@ usage", quota.providerFilter.displayLabel))
|
|
.font(.system(size: 11, weight: .semibold))
|
|
if case .stale = quota.connection {
|
|
Text(L("stale"))
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.secondary)
|
|
} else if case .transientFailure = quota.connection {
|
|
Text(L("retrying"))
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Spacer()
|
|
if let plan = quota.planLabel, !plan.isEmpty {
|
|
Text(plan)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.secondary.opacity(0.12))
|
|
)
|
|
// Size to content. Plan names are bounded short strings
|
|
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
|
|
// maxWidth was making short labels look stretched.
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
}
|
|
ForEach(Array(quota.details.enumerated()), id: \.offset) { index, w in
|
|
QuotaDetailRow(window: w)
|
|
}
|
|
if !quota.footerLines.isEmpty {
|
|
Divider()
|
|
.padding(.top, 2)
|
|
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func terminalFailureCard(reason: String?) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(reconnectPresentation.title)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.foregroundStyle(.red)
|
|
Text(reason ?? reconnectPresentation.defaultReason)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
Text(reconnectPresentation.instruction)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var reconnectPresentation: ProviderReconnectPresentation {
|
|
ProviderReconnectPresentation(provider: quota.providerFilter)
|
|
}
|
|
}
|
|
|
|
private struct QuotaDetailRow: View {
|
|
let window: QuotaSummary.Window
|
|
|
|
private static let labelWidth: CGFloat = 92
|
|
private static let rowSpacing: CGFloat = 8
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: Self.rowSpacing) {
|
|
Text(window.label)
|
|
.font(.system(size: 10.5))
|
|
.frame(width: Self.labelWidth, alignment: .leading)
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.secondary.opacity(0.18))
|
|
Capsule()
|
|
.fill(barColor)
|
|
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
Text(window.percentLabel)
|
|
.font(.codeMono(size: 10.5, weight: .medium))
|
|
.frame(width: 36, alignment: .trailing)
|
|
if !window.resetsInLabel.isEmpty {
|
|
Text(window.resetsInLabel)
|
|
.font(.codeMono(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 50, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var barColor: Color {
|
|
switch QuotaSummary.severity(for: window.percent) {
|
|
case .normal: return Color.green.opacity(0.85)
|
|
case .warning: return Color.yellow
|
|
case .critical: return Color.orange
|
|
case .danger: return Color.red
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ProviderFilter {
|
|
@MainActor var color: Color {
|
|
switch self {
|
|
case .all: return Theme.brandAccent
|
|
case .claude: return Theme.categoricalClaude
|
|
case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0)
|
|
case .codewhale: return Color(red: 0x38/255.0, green: 0xBD/255.0, blue: 0xF8/255.0)
|
|
case .codex: return Theme.categoricalCodex
|
|
case .cursor: return Theme.categoricalCursor
|
|
case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0)
|
|
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
|
case .devin: return Color(red: 0x25/255.0, green: 0xA0/255.0, blue: 0x8D/255.0)
|
|
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
|
|
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
|
|
case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
|
|
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
|
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
|
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
|
|
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
|
|
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
|
|
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
|
case .openclaude: return Color(red: 0xC2/255.0, green: 0x41/255.0, blue: 0x6B/255.0)
|
|
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
|
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
|
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0)
|
|
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
|
|
case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0)
|
|
case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0)
|
|
case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0)
|
|
case .grok: return Color(red: 0x8E/255.0, green: 0x8E/255.0, blue: 0x93/255.0)
|
|
case .hermes: return Color(red: 0xC7/255.0, green: 0x52/255.0, blue: 0x3E/255.0)
|
|
case .zcode: return Color(red: 0x52/255.0, green: 0x6E/255.0, blue: 0xD6/255.0)
|
|
}
|
|
}
|
|
}
|