// // ModelChipView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The header model chip and its picker popover — the same catalog as Zyquo // Cloud, with the Agent twist: agent-capable models are emphasized (and the // recommended tier surfaced first, default agent model starred), while // models without reliable multi-step tool use are dimmed and labeled // "limited tool use". // 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) if let model, !model.agentCapable { Image(systemName: "exclamationmark.triangle") .font(.system(size: 9)) .foregroundStyle(ZyquoColor.warning) .help("Limited tool use — not recommended for agent tasks") } 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) } } } } /// Picker popover: search + recommended agent tier + provider groups (full /// shared catalog; non-agent-capable entries dimmed). struct ModelPickerView: View { let selected: AIModel? var onSelect: (AIModel) -> Void @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var settings: AgentSettingsStore @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 query.isEmpty, !catalog.recommendedAgentModels.isEmpty { sectionHeader("Recommended for agent tasks") ForEach(catalog.recommendedAgentModels) { 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: 340, height: 400) } .background(ZyquoColor.surface) } /// Providers with a saved key first; models filtered by the search text. 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) } } // Agent-capable first within each provider. models = models.sorted { ($0.agentCapable ? 0 : 1) < ($1.agentCapable ? 0 : 1) } 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 { let defaultModel = settings.defaultAgentModel(in: catalog) let isDefault = model.id == defaultModel?.id && model.provider == defaultModel?.provider let isSelected = model.id == selected?.id && model.provider == selected?.provider return Button { onSelect(model) } label: { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: model.provider.symbolName) .font(.system(size: 11)) .foregroundStyle(model.agentCapable ? ZyquoColor.accent : ZyquoColor.textTertiary) .frame(width: 14) Text(model.displayName) .font(model.agentCapable ? ZyquoFont.bodyEmphasis(size: 12.5) : ZyquoFont.body(size: 12.5)) .foregroundStyle(model.agentCapable ? ZyquoColor.textPrimary : ZyquoColor.textTertiary) .lineLimit(1) if isDefault { Image(systemName: "star.fill") .font(.system(size: 9)) .foregroundStyle(ZyquoColor.warning) .help("Default agent model") } Spacer(minLength: ZyquoSpacing.xs) if !model.agentCapable { Text("limited tool use") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } if model.capabilities.reasoning { Image(systemName: "brain") .font(.system(size: 9)) .foregroundStyle(ZyquoColor.textTertiary) } Text(model.contextBadge) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(isSelected ? ZyquoColor.accentSubtle : .clear) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .zyquoHoverHighlight() } }