// // FooterView.swift // oAI // // Footer bar with session summary // // 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 FooterView: View { let stats: SessionStats var body: some View { HStack(spacing: 20) { // Session summary HStack(spacing: 16) { FooterItem( icon: "message", label: "Messages", value: "\(stats.messageCount)" ) FooterItem( icon: "chart.bar.xaxis", label: "Tokens", value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)" ) FooterItem( icon: "dollarsign.circle", label: "Cost", value: stats.totalCostDisplay ) // Git sync status (if enabled) if SettingsService.shared.syncEnabled && SettingsService.shared.syncAutoSave { SyncStatusFooter() } } Spacer() // Shortcuts hint #if os(macOS) Text("⌘M Model • ⌘K Clear • ⌘S Stats") .font(.caption2) .foregroundColor(.oaiSecondary) #endif } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.ultraThinMaterial) .overlay( Rectangle() .fill(Color.oaiBorder.opacity(0.5)) .frame(height: 1), alignment: .top ) } } struct FooterItem: View { let icon: String let label: String let value: String private let guiSize = SettingsService.shared.guiTextSize var body: some View { HStack(spacing: 6) { Image(systemName: icon) .font(.system(size: guiSize - 2)) .foregroundColor(.oaiSecondary) Text(label + ":") .font(.system(size: guiSize - 2)) .foregroundColor(.oaiSecondary) Text(value) .font(.system(size: guiSize - 2, weight: .medium)) .foregroundColor(.oaiPrimary) } } } struct SyncStatusFooter: View { private let gitSync = GitSyncService.shared private let settings = SettingsService.shared private let guiSize = SettingsService.shared.guiTextSize @State private var syncText = "Not Synced" @State private var syncColor: Color = .secondary var body: some View { HStack(spacing: 6) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: guiSize - 2)) .foregroundColor(syncColor) Text(syncText) .font(.system(size: guiSize - 2, weight: .medium)) .foregroundColor(syncColor) } .onAppear { updateSyncStatus() } .onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() } .onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() } .onChange(of: gitSync.lastSyncError) { updateSyncStatus() } .onChange(of: gitSync.isSyncing) { updateSyncStatus() } .onChange(of: settings.syncConfigured) { updateSyncStatus() } } private func updateSyncStatus() { if let error = gitSync.lastSyncError { syncText = "Sync Error" syncColor = .red } else if gitSync.isSyncing { syncText = "Syncing..." syncColor = .orange } else if let lastSync = gitSync.syncStatus.lastSyncTime { syncText = "Last Sync: \(timeAgo(lastSync))" syncColor = .green } else if gitSync.syncStatus.isCloned { syncText = "Sync: Ready" syncColor = .secondary } else if settings.syncConfigured { syncText = "Sync: Not Initialized" syncColor = .orange } else { syncText = "Sync: Off" syncColor = .secondary } } 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 { Spacer() FooterView(stats: SessionStats( totalInputTokens: 1250, totalOutputTokens: 3420, totalCost: 0.0152, messageCount: 12 )) } .background(Color.oaiBackground) }