spb/zyquo-cloud Public MIT
Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.
Swift 97.4%
Shell 1.7%
Makefile 1%
1//2// ModelChipView.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The model chip (provider glyph + model name) and its picker popover:9// search, favorites first, grouped by provider, capability badges.10//1112import SwiftUI1314struct ModelChipView: View {15 let model: AIModel?16 var onSelect: (AIModel) -> Void1718 @State private var showingPicker = false1920 var body: some View {21 Button {22 showingPicker.toggle()23 } label: {24 HStack(spacing: ZyquoSpacing.xxs) {25 Image(systemName: model?.provider.symbolName ?? "questionmark.circle")26 .font(.system(size: 11, weight: .medium))27 .foregroundStyle(ZyquoColor.accent)28 Text(model?.displayName ?? "Choose Model")29 .font(ZyquoFont.bodyEmphasis(size: 12.5))30 .foregroundStyle(ZyquoColor.textPrimary)31 .lineLimit(1)32 Image(systemName: "chevron.down")33 .font(.system(size: 8, weight: .semibold))34 .foregroundStyle(ZyquoColor.textTertiary)35 }36 .padding(.horizontal, ZyquoSpacing.xs)37 .padding(.vertical, 4)38 .background(39 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)40 .fill(ZyquoColor.surfaceSecondary)41 )42 }43 .buttonStyle(PressableButtonStyle())44 .popover(isPresented: $showingPicker, arrowEdge: .bottom) {45 ModelPickerView(selected: model) { chosen in46 showingPicker = false47 onSelect(chosen)48 }49 }50 }51}5253/// Model picker popover content: search field + favorites + provider groups.54struct ModelPickerView: View {55 let selected: AIModel?56 var onSelect: (AIModel) -> Void5758 @EnvironmentObject private var catalog: ModelCatalog59 @EnvironmentObject private var vault: KeyVaultStore60 @State private var query = ""6162 var body: some View {63 VStack(spacing: 0) {64 HStack(spacing: ZyquoSpacing.xxs) {65 Image(systemName: "magnifyingglass")66 .font(.system(size: 11))67 .foregroundStyle(ZyquoColor.textTertiary)68 TextField("Search models", text: $query)69 .textFieldStyle(.plain)70 .font(ZyquoFont.body(size: 12.5))71 }72 .padding(ZyquoSpacing.xs)73 ZyquoHairline()74 ScrollView {75 LazyVStack(alignment: .leading, spacing: 1) {76 if !favorites.isEmpty && query.isEmpty {77 sectionHeader("Favorites")78 ForEach(favorites) { model in row(model) }79 }80 ForEach(providerGroups, id: \.0) { provider, models in81 sectionHeader(provider.displayName)82 ForEach(models) { model in row(model) }83 }84 }85 .padding(ZyquoSpacing.xxs)86 }87 .frame(width: 320, height: 380)88 }89 .background(ZyquoColor.surface)90 }9192 private var favorites: [AIModel] {93 catalog.all.filter { catalog.favoriteIDs.contains($0.id) }94 }9596 /// Providers with a saved key first, then the rest; models filtered by search.97 private var providerGroups: [(ProviderID, [AIModel])] {98 let ordered = ProviderID.builtIn.sorted {99 (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName)100 }101 return ordered.compactMap { provider in102 var models = catalog.models(for: provider)103 if !query.isEmpty {104 models = models.filter {105 $0.displayName.localizedCaseInsensitiveContains(query)106 || $0.id.localizedCaseInsensitiveContains(query)107 }108 }109 return models.isEmpty ? nil : (provider, models)110 }111 }112113 private func sectionHeader(_ title: String) -> some View {114 Text(title)115 .font(ZyquoFont.caption)116 .foregroundStyle(ZyquoColor.textTertiary)117 .padding(.horizontal, ZyquoSpacing.xs)118 .padding(.top, ZyquoSpacing.xs)119 .padding(.bottom, 2)120 }121122 private func row(_ model: AIModel) -> some View {123 Button {124 onSelect(model)125 } label: {126 HStack(spacing: ZyquoSpacing.xs) {127 Image(systemName: model.provider.symbolName)128 .font(.system(size: 11))129 .foregroundStyle(ZyquoColor.accent)130 .frame(width: 14)131 Text(model.displayName)132 .font(ZyquoFont.body(size: 12.5))133 .foregroundStyle(ZyquoColor.textPrimary)134 .lineLimit(1)135 Spacer(minLength: ZyquoSpacing.xs)136 capabilityBadges(model)137 Text(model.contextBadge)138 .font(ZyquoFont.caption)139 .foregroundStyle(ZyquoColor.textTertiary)140 Button {141 toggleFavorite(model)142 } label: {143 Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star")144 .font(.system(size: 10))145 .foregroundStyle(146 catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary147 )148 }149 .buttonStyle(.plain)150 }151 .padding(.horizontal, ZyquoSpacing.xs)152 .padding(.vertical, 4)153 .background(154 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)155 .fill(model.id == selected?.id && model.provider == selected?.provider156 ? ZyquoColor.accentSubtle : .clear)157 )158 .contentShape(Rectangle())159 }160 .buttonStyle(.plain)161 .zyquoHoverHighlight()162 }163164 @ViewBuilder165 private func capabilityBadges(_ model: AIModel) -> some View {166 HStack(spacing: 3) {167 if model.capabilities.vision {168 Image(systemName: "eye").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)169 }170 if model.capabilities.reasoning {171 Image(systemName: "brain").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)172 }173 if model.capabilities.tools {174 Image(systemName: "wrench").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)175 }176 }177 }178179 private func toggleFavorite(_ model: AIModel) {180 if catalog.favoriteIDs.contains(model.id) {181 catalog.favoriteIDs.remove(model.id)182 } else {183 catalog.favoriteIDs.insert(model.id)184 }185 }186}187