// // HeaderView.swift // oAI // // Header bar with provider, model, and stats // // 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 HeaderView: View { let provider: Settings.Provider let model: ModelInfo? let stats: SessionStats let onlineMode: Bool let mcpEnabled: Bool let mcpStatus: String? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void private let settings = SettingsService.shared private let registry = ProviderRegistry.shared private let gitSync = GitSyncService.shared var body: some View { HStack(spacing: 12) { // Provider picker dropdown — only shows configured providers Menu { ForEach(registry.configuredProviders, id: \.self) { p in Button { onProviderChange(p) } label: { HStack { Image(systemName: p.iconName) Text(p.displayName) if p == provider { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Image(systemName: provider.iconName) .font(.system(size: settings.guiTextSize - 2)) Text(provider.displayName) .font(.system(size: settings.guiTextSize - 2, weight: .medium)) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 8)) .opacity(0.7) } .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.providerColor(provider)) .cornerRadius(4) } .menuStyle(.borderlessButton) .fixedSize() .help("Switch provider") // Model info (clickable → model selector) Button(action: onModelSelect) { if let model = model { HStack(spacing: 6) { Text(model.name) .font(.system(size: settings.guiTextSize, weight: .medium)) .foregroundColor(.oaiPrimary) // Capability badges HStack(spacing: 3) { if model.capabilities.vision { Image(systemName: "eye") .font(.system(size: 9)) .foregroundColor(.oaiSecondary) } if model.capabilities.tools { Image(systemName: "wrench") .font(.system(size: 9)) .foregroundColor(.oaiSecondary) } if model.capabilities.online { Image(systemName: "globe") .font(.system(size: 9)) .foregroundColor(.oaiSecondary) } if model.capabilities.imageGeneration { Image(systemName: "paintbrush") .font(.system(size: 9)) .foregroundColor(.oaiSecondary) } } Image(systemName: "chevron.down") .font(.caption2) .foregroundColor(.oaiSecondary) } } else { HStack(spacing: 4) { Text("No model selected") .font(.system(size: settings.guiTextSize)) .foregroundColor(.oaiSecondary) Image(systemName: "chevron.down") .font(.caption2) .foregroundColor(.oaiSecondary) } } } .buttonStyle(.plain) .help("Select model") Spacer() // Status indicators HStack(spacing: 8) { if model?.capabilities.imageGeneration == true { StatusPill(icon: "paintbrush", label: "Image", color: .purple) } if onlineMode { StatusPill(icon: "globe", label: "Online", color: .green) } if mcpEnabled { StatusPill(icon: "folder", label: "MCP", color: .blue) } if settings.syncEnabled && settings.syncAutoSave { SyncStatusPill() } } // Divider between status and stats if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) { Divider() .frame(height: 16) .opacity(0.5) } // Quick stats HStack(spacing: 16) { StatItem(icon: "message", value: "\(stats.messageCount)") StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay) StatItem(icon: "dollarsign", value: stats.totalCostDisplay) } .font(.caption) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(.ultraThinMaterial) .overlay( Rectangle() .fill(Color.oaiBorder.opacity(0.5)) .frame(height: 1), alignment: .bottom ) } } struct StatItem: View { let icon: String let value: String private let settings = SettingsService.shared var body: some View { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: settings.guiTextSize - 3)) .foregroundColor(.oaiSecondary) Text(value) .font(.system(size: settings.guiTextSize - 1, weight: .medium)) .foregroundColor(.oaiPrimary) } } } struct StatusPill: View { let icon: String let label: String let color: Color var body: some View { HStack(spacing: 3) { Circle() .fill(color) .frame(width: 6, height: 6) Text(label) .font(.system(size: 10, weight: .medium)) .foregroundColor(.oaiSecondary) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(color.opacity(0.1), in: Capsule()) } } struct SyncStatusPill: View { private let gitSync = GitSyncService.shared @State private var syncColor: Color = .secondary @State private var syncLabel: String = "Sync" @State private var tooltipText: String = "" var body: some View { HStack(spacing: 3) { Circle() .fill(syncColor) .frame(width: 6, height: 6) Text(syncLabel) .font(.system(size: 10, weight: .medium)) .foregroundColor(.oaiSecondary) } .padding(.horizontal, 6) .padding(.vertical, 2) .background(syncColor.opacity(0.1), in: Capsule()) .help(tooltipText) .onAppear { updateState() } .onChange(of: gitSync.syncStatus) { updateState() } .onChange(of: gitSync.isSyncing) { updateState() } .onChange(of: gitSync.lastSyncError) { updateState() } } private func updateState() { // Determine sync state if let error = gitSync.lastSyncError { syncColor = .red syncLabel = "Error" tooltipText = "Sync failed: \(error)" } else if gitSync.isSyncing { syncColor = .orange syncLabel = "Syncing" tooltipText = "Syncing..." } else if gitSync.syncStatus.isCloned { syncColor = .green syncLabel = "Synced" if let lastSync = gitSync.syncStatus.lastSyncTime { tooltipText = "Last synced: \(timeAgo(lastSync))" } else { tooltipText = "Synced" } } else { syncColor = .secondary syncLabel = "Sync" tooltipText = "Sync not configured" } } private func timeAgo(_ date: Date) -> String { let seconds = Int(Date().timeIntervalSince(date)) if seconds < 60 { return "just now" } else if seconds < 3600 { let minutes = seconds / 60 return "\(minutes)m ago" } else if seconds < 86400 { let hours = seconds / 3600 return "\(hours)h ago" } else { let days = seconds / 86400 return "\(days)d ago" } } } #Preview { VStack { HeaderView( provider: .openrouter, model: ModelInfo.mockModels.first, stats: SessionStats( totalInputTokens: 125, totalOutputTokens: 434, totalCost: 0.00111, messageCount: 4 ), onlineMode: true, mcpEnabled: true, mcpStatus: "MCP", onModelSelect: {}, onProviderChange: { _ in } ) Spacer() } .background(Color.oaiBackground) }