// // 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 keyboardIndex: Int = -1 private var filteredModels: [ModelInfo] { models.filter { model in let matchesSearch = searchText.isEmpty || model.name.lowercased().contains(searchText.lowercased()) || model.id.lowercased().contains(searchText.lowercased()) let matchesVision = !filterVision || model.capabilities.vision let matchesTools = !filterTools || model.capabilities.tools let matchesOnline = !filterOnline || model.capabilities.online let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen } } var body: some View { NavigationStack { VStack(spacing: 0) { // Search bar TextField("Search models...", text: $searchText) .textFieldStyle(.roundedBorder) .padding() .onChange(of: searchText) { // Reset keyboard index when search changes keyboardIndex = -1 } // Filters 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") } .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 ) .id(model.id) .contentShape(Rectangle()) .onTapGesture { onSelect(model) } } .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 { // Initialize keyboard index to current selection if let selected = selectedModel, let index = filteredModels.firstIndex(where: { $0.id == selected.id }) { keyboardIndex = index } } } } } struct FilterToggle: View { @Binding var isOn: Bool let icon: String let label: String 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) } } struct ModelRowView: View { let model: ModelInfo let isSelected: Bool var isKeyboardHighlighted: Bool = false var body: some View { VStack(alignment: .leading, spacing: 6) { HStack { Text(model.name) .font(.headline) .foregroundColor(isSelected ? .blue : .primary) Spacer() // Capabilities 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 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) } .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 } ) }