// // 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 ) } // 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: String { 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: 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 { 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) }