289 lines
9.6 KiB
Swift
289 lines
9.6 KiB
Swift
//
|
|
// HeaderView.swift
|
|
// oAI
|
|
//
|
|
// Header bar with provider, model, and stats
|
|
//
|
|
|
|
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)
|
|
}
|