// // 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 let conversationName: String? let hasUnsavedChanges: Bool let onQuickSave: (() -> Void)? init(stats: SessionStats, conversationName: String? = nil, hasUnsavedChanges: Bool = false, onQuickSave: (() -> Void)? = nil) { self.stats = stats self.conversationName = conversationName self.hasUnsavedChanges = hasUnsavedChanges self.onQuickSave = onQuickSave } 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() // Save indicator (only when chat has messages) if stats.messageCount > 0 { SaveIndicator( conversationName: conversationName, hasUnsavedChanges: hasUnsavedChanges, onSave: onQuickSave ) } // Update available badge #if os(macOS) UpdateBadge() #endif // Shortcuts hint #if os(macOS) Text("⌘N New • ⌘M Model • ⌘S Save") .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 SaveIndicator: View { let conversationName: String? let hasUnsavedChanges: Bool let onSave: (() -> Void)? private let guiSize = SettingsService.shared.guiTextSize private var isSaved: Bool { conversationName != nil } private var isModified: Bool { isSaved && hasUnsavedChanges } private var isUnsaved: Bool { !isSaved } private var label: String { if let name = conversationName { return name.count > 20 ? String(name.prefix(20)) + "…" : name } return "Unsaved" } private var icon: String { if isModified { return "circle.fill" } if isSaved { return "checkmark.circle.fill" } return "exclamationmark.circle" } private var color: Color { if isModified { return .orange } if isSaved { return .green } return .secondary } private var tooltip: LocalizedStringKey { if isModified { return "Click to re-save \"\(conversationName ?? "")\"" } if isSaved { return "Saved — no changes" } return "Not saved — use /save " } var body: some View { Button(action: { if isModified { onSave?() } }) { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: guiSize - 3)) .foregroundColor(color) Text(label) .font(.system(size: guiSize - 2)) .foregroundColor(isUnsaved ? .secondary : .oaiPrimary) } } .buttonStyle(.plain) .help(tooltip) .disabled(!isModified) .opacity(isUnsaved ? 0.6 : 1.0) .animation(.easeInOut(duration: 0.2), value: conversationName) .animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges) } } struct FooterItem: View { let icon: String let label: LocalizedStringKey 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 private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) } @State private var syncState: SyncState = .off @State private var syncColor: Color = .secondary private var syncText: LocalizedStringKey { switch syncState { case .off: return "Sync: Off" case .notInitialized: return "Sync: Not Initialized" case .ready: return "Sync: Ready" case .syncing: return "Syncing..." case .error: return "Sync Error" case .synced(let date): let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now) return "Last Sync: \(rel)" } } 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 gitSync.lastSyncError != nil { syncState = .error; syncColor = .red } else if gitSync.isSyncing { syncState = .syncing; syncColor = .orange } else if let lastSync = gitSync.syncStatus.lastSyncTime { syncState = .synced(lastSync); syncColor = .green } else if gitSync.syncStatus.isCloned { syncState = .ready; syncColor = .secondary } else if settings.syncConfigured { syncState = .notInitialized; syncColor = .orange } else { syncState = .off; syncColor = .secondary } } } struct UpdateBadge: View { private let updater = UpdateCheckService.shared private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" var body: some View { if updater.updateAvailable { Button(action: { updater.openReleasesPage() }) { HStack(spacing: 4) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 10)) Text("Update Available\(updater.latestVersion.map { " (v\($0))" } ?? "")") .font(.caption2) .fontWeight(.medium) } .foregroundColor(.green) } .buttonStyle(.plain) .help("A new version is available — click to open the releases page") } else { Text("v\(currentVersion)") .font(.caption2) .foregroundColor(.oaiSecondary) } } } #Preview { let stats = SessionStats( totalInputTokens: 1250, totalOutputTokens: 3420, totalCost: 0.0152, messageCount: 12 ) return VStack(spacing: 0) { Spacer() FooterView(stats: stats, conversationName: nil, hasUnsavedChanges: true) FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: true) FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: false) } .background(Color.oaiBackground) }