// // ModelSelectorView.swift // oAI // // Model selection screen // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI struct ModelSelectorView: View { let models: [ModelInfo] let selectedModel: ModelInfo? let onSelect: (ModelInfo) -> Void @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var filterVision = false @State private var filterTools = false @State private var filterOnline = false @State private var filterImageGen = false @State private var filterThinking = false @State private var keyboardIndex: Int = -1 @State private var sortOrder: ModelSortOrder = .default @State private var selectedInfoModel: ModelInfo? = nil private var filteredModels: [ModelInfo] { let q = searchText.lowercased() let filtered = models.filter { model in let matchesSearch = searchText.isEmpty || model.name.lowercased().contains(q) || model.id.lowercased().contains(q) || model.description?.lowercased().contains(q) == true let matchesVision = !filterVision || model.capabilities.vision let matchesTools = !filterTools || model.capabilities.tools let matchesOnline = !filterOnline || model.capabilities.online let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration let matchesThinking = !filterThinking || model.capabilities.thinking return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking } switch sortOrder { case .default: return filtered case .priceLowHigh: return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt } case .priceHighLow: return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt } case .contextHighLow: return filtered.sorted { $0.contextLength > $1.contextLength } } } var body: some View { NavigationStack { VStack(spacing: 0) { // Search bar TextField("Search models...", text: $searchText) .textFieldStyle(.roundedBorder) .padding() .onChange(of: searchText) { keyboardIndex = -1 } // Filters + Sort HStack(spacing: 12) { FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision") FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools") FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online") FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen") FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking") Spacer() // Sort menu Menu { ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in Button { sortOrder = order keyboardIndex = -1 } label: { if sortOrder == order { Label(order.label, systemImage: "checkmark") } else { Text(order.label) } } } } label: { HStack(spacing: 4) { Image(systemName: "arrow.up.arrow.down") Text("Sort") } .font(.caption) .padding(.horizontal, 10) .padding(.vertical, 6) .background(sortOrder != .default ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1)) .foregroundColor(sortOrder != .default ? .blue : .secondary) .cornerRadius(6) } .menuStyle(.borderlessButton) .fixedSize() } .padding(.horizontal) .padding(.bottom, 12) Divider() // Model list if filteredModels.isEmpty { ContentUnavailableView( "No Models Found", systemImage: "magnifyingglass", description: Text("Try adjusting your search or filters") ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in ModelRowView( model: model, isSelected: model.id == selectedModel?.id, isKeyboardHighlighted: index == keyboardIndex, onSelect: { onSelect(model) }, onInfo: { selectedInfoModel = model } ) .id(model.id) } .listStyle(.plain) .onChange(of: keyboardIndex) { _, newIndex in if newIndex >= 0 && newIndex < filteredModels.count { withAnimation { proxy.scrollTo(filteredModels[newIndex].id, anchor: .center) } } } } } } .frame(minWidth: 600, minHeight: 500) .navigationTitle("Select Model") #if os(macOS) .onKeyPress(.downArrow) { if keyboardIndex < filteredModels.count - 1 { keyboardIndex += 1 } return .handled } .onKeyPress(.upArrow) { if keyboardIndex > 0 { keyboardIndex -= 1 } else if keyboardIndex == -1 && !filteredModels.isEmpty { keyboardIndex = 0 } return .handled } .onKeyPress(.return) { if keyboardIndex >= 0 && keyboardIndex < filteredModels.count { onSelect(filteredModels[keyboardIndex]) return .handled } return .ignored } #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .onAppear { if let selected = selectedModel, let index = filteredModels.firstIndex(where: { $0.id == selected.id }) { keyboardIndex = index } } .sheet(item: $selectedInfoModel) { model in ModelInfoView(model: model) } } } } // MARK: - Sort Order enum ModelSortOrder: String, CaseIterable { case `default` case priceLowHigh case priceHighLow case contextHighLow var label: LocalizedStringKey { switch self { case .default: return "Default" case .priceLowHigh: return "Price: Low to High" case .priceHighLow: return "Price: High to Low" case .contextHighLow: return "Context: High to Low" } } } // MARK: - Filter Toggle struct FilterToggle: View { @Binding var isOn: Bool let icon: String let label: LocalizedStringKey var body: some View { Button(action: { isOn.toggle() }) { HStack(spacing: 4) { Text(icon) Text(label) } .font(.caption) .padding(.horizontal, 10) .padding(.vertical, 6) .background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1)) .foregroundColor(isOn ? .blue : .secondary) .cornerRadius(6) } .buttonStyle(.plain) } } // MARK: - Model Row struct ModelRowView: View { let model: ModelInfo let isSelected: Bool var isKeyboardHighlighted: Bool = false let onSelect: () -> Void let onInfo: () -> Void var body: some View { HStack(alignment: .top, spacing: 8) { // Selectable main content VStack(alignment: .leading, spacing: 6) { HStack { Text(model.name) .font(.headline) .foregroundColor(isSelected ? .blue : .primary) if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.blue) } } Text(model.id) .font(.caption) .foregroundColor(.secondary) if let description = model.description { Text(description) .font(.caption) .foregroundColor(.secondary) .lineLimit(2) } HStack(spacing: 12) { Label(model.contextLengthDisplay, systemImage: "text.alignleft") Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle") } .font(.caption2) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { onSelect() } // Right side: capabilities + info button VStack(alignment: .trailing, spacing: 6) { // Capability icons HStack(spacing: 4) { if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) } if model.capabilities.tools { Text("\u{1F527}").font(.caption) } if model.capabilities.online { Text("\u{1F310}").font(.caption) } if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) } if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) } } // Info button Button(action: onInfo) { Image(systemName: "info.circle") .font(.caption) .foregroundColor(.secondary) .frame(width: 20, height: 20) } .buttonStyle(.plain) .help("Show model info") } .padding(.top, 2) } .padding(.vertical, 6) .padding(.horizontal, isKeyboardHighlighted ? 4 : 0) .background( isKeyboardHighlighted ? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15)) : nil ) } } #Preview { ModelSelectorView( models: ModelInfo.mockModels, selectedModel: ModelInfo.mockModels.first, onSelect: { _ in } ) }