// // ModelChipView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The model chip (provider glyph + model name) and its picker popover: // search, favorites first, grouped by provider, capability badges. // import SwiftUI struct ModelChipView: View { let model: AIModel? var onSelect: (AIModel) -> Void @State private var showingPicker = false var body: some View { Button { showingPicker.toggle() } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: model?.provider.symbolName ?? "questionmark.circle") .font(.system(size: 11, weight: .medium)) .foregroundStyle(ZyquoColor.accent) Text(model?.displayName ?? "Choose Model") .font(ZyquoFont.bodyEmphasis(size: 12.5)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) Image(systemName: "chevron.down") .font(.system(size: 8, weight: .semibold)) .foregroundStyle(ZyquoColor.textTertiary) } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } .buttonStyle(PressableButtonStyle()) .popover(isPresented: $showingPicker, arrowEdge: .bottom) { ModelPickerView(selected: model) { chosen in showingPicker = false onSelect(chosen) } } } } /// Model picker popover content: search field + favorites + provider groups. struct ModelPickerView: View { let selected: AIModel? var onSelect: (AIModel) -> Void @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @State private var query = "" var body: some View { VStack(spacing: 0) { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textTertiary) TextField("Search models", text: $query) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 12.5)) } .padding(ZyquoSpacing.xs) ZyquoHairline() ScrollView { LazyVStack(alignment: .leading, spacing: 1) { if !favorites.isEmpty && query.isEmpty { sectionHeader("Favorites") ForEach(favorites) { model in row(model) } } ForEach(providerGroups, id: \.0) { provider, models in sectionHeader(provider.displayName) ForEach(models) { model in row(model) } } } .padding(ZyquoSpacing.xxs) } .frame(width: 320, height: 380) } .background(ZyquoColor.surface) } private var favorites: [AIModel] { catalog.all.filter { catalog.favoriteIDs.contains($0.id) } } /// Providers with a saved key first, then the rest; models filtered by search. private var providerGroups: [(ProviderID, [AIModel])] { let ordered = ProviderID.builtIn.sorted { (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName) } return ordered.compactMap { provider in var models = catalog.models(for: provider) if !query.isEmpty { models = models.filter { $0.displayName.localizedCaseInsensitiveContains(query) || $0.id.localizedCaseInsensitiveContains(query) } } return models.isEmpty ? nil : (provider, models) } } private func sectionHeader(_ title: String) -> some View { Text(title) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .padding(.horizontal, ZyquoSpacing.xs) .padding(.top, ZyquoSpacing.xs) .padding(.bottom, 2) } private func row(_ model: AIModel) -> some View { Button { onSelect(model) } label: { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: model.provider.symbolName) .font(.system(size: 11)) .foregroundStyle(ZyquoColor.accent) .frame(width: 14) Text(model.displayName) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) Spacer(minLength: ZyquoSpacing.xs) capabilityBadges(model) Text(model.contextBadge) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) Button { toggleFavorite(model) } label: { Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star") .font(.system(size: 10)) .foregroundStyle( catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary ) } .buttonStyle(.plain) } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(model.id == selected?.id && model.provider == selected?.provider ? ZyquoColor.accentSubtle : .clear) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .zyquoHoverHighlight() } @ViewBuilder private func capabilityBadges(_ model: AIModel) -> some View { HStack(spacing: 3) { if model.capabilities.vision { Image(systemName: "eye").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) } if model.capabilities.reasoning { Image(systemName: "brain").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) } if model.capabilities.tools { Image(systemName: "wrench").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary) } } } private func toggleFavorite(_ model: AIModel) { if catalog.favoriteIDs.contains(model.id) { catalog.favoriteIDs.remove(model.id) } else { catalog.favoriteIDs.insert(model.id) } } }