diff --git a/Sources/CodexBar/EnvironmentalImpactSectionView.swift b/Sources/CodexBar/EnvironmentalImpactSectionView.swift new file mode 100644 index 000000000..eb7d437e8 --- /dev/null +++ b/Sources/CodexBar/EnvironmentalImpactSectionView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct EnvironmentalImpactSectionView: View { + let lines: [String] + let hintLine: String? + let textFont: Font + + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + if !self.lines.isEmpty { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "leaf") + .foregroundColor(.green) + .imageScale(.small) + Text("environmental_impact_header") + .font(.body) + .fontWeight(.medium) + } + .padding(.top, 4) + + ForEach(self.lines, id: \.self) { line in + Text(line) + .font(self.textFont) + } + + if let hintLine, !hintLine.isEmpty { + Text(hintLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 577cc0e37..07cc55457 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -56,13 +56,57 @@ extension UsageMenuCardView.Model { } return "\(windowLabel): \(monthCost)" }() + + let energySessionLine: String? + let co2SessionLine: String? + if let sTokens = snapshot.sessionTokens { + let impact = EnvironmentalImpact(tokens: sTokens) + energySessionLine = L( + "environmental_impact_energy_today", + UsageFormatter.formatEnergy(impact.energyKWh), + impact.smartphoneCharges, + impact.kettleBoils) + co2SessionLine = L( + "environmental_impact_co2_today", + UsageFormatter.formatCO2(impact.co2Kg), + impact.carKm) + } else { + energySessionLine = nil + co2SessionLine = nil + } + + let energyMonthLine: String? + let co2MonthLine: String? + if let mTokens = monthTokensValue { + let impact = EnvironmentalImpact(tokens: mTokens) + energyMonthLine = L( + "environmental_impact_energy_month", + windowLabel, + UsageFormatter.formatEnergy(impact.energyKWh), + impact.smartphoneCharges, + impact.kettleBoils) + co2MonthLine = L( + "environmental_impact_co2_month", + windowLabel, + UsageFormatter.formatCO2(impact.co2Kg), + impact.carKm) + } else { + energyMonthLine = nil + co2MonthLine = nil + } + let err = (error?.isEmpty ?? true) ? nil : error return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, - errorCopyText: (error?.isEmpty ?? true) ? nil : error) + errorCopyText: (error?.isEmpty ?? true) ? nil : error, + energySessionLine: energySessionLine, + co2SessionLine: co2SessionLine, + energyMonthLine: energyMonthLine, + co2MonthLine: co2MonthLine, + environmentalImpactHintLine: L("environmental_impact_hint")) } static func tokenUsageHint(provider: UsageProvider) -> String? { diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 499be19ea..3782694d1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import AppKit import CodexBarCore import SwiftUI @@ -86,6 +87,46 @@ struct UsageMenuCardView: View { let hintLine: String? let errorLine: String? let errorCopyText: String? + let energySessionLine: String? + let co2SessionLine: String? + let energyMonthLine: String? + let co2MonthLine: String? + let environmentalImpactHintLine: String? + + init( + sessionLine: String, + monthLine: String, + hintLine: String? = nil, + errorLine: String? = nil, + errorCopyText: String? = nil, + energySessionLine: String? = nil, + co2SessionLine: String? = nil, + energyMonthLine: String? = nil, + co2MonthLine: String? = nil, + environmentalImpactHintLine: String? = nil) + { + self.sessionLine = sessionLine + self.monthLine = monthLine + self.hintLine = hintLine + self.errorLine = errorLine + self.errorCopyText = errorCopyText + self.energySessionLine = energySessionLine + self.co2SessionLine = co2SessionLine + self.energyMonthLine = energyMonthLine + self.co2MonthLine = co2MonthLine + self.environmentalImpactHintLine = environmentalImpactHintLine + } + + var environmentalImpactLines: [String] { + [ + self.energySessionLine, + self.co2SessionLine, + self.energyMonthLine, + self.co2MonthLine, + ] + .compactMap(\.self) + .filter { !$0.isEmpty } + } } struct ProviderCostSection { @@ -197,6 +238,12 @@ struct UsageMenuCardView: View { .font(.footnote) Text(tokenUsage.monthLine) .font(.footnote) + + EnvironmentalImpactSectionView( + lines: tokenUsage.environmentalImpactLines, + hintLine: tokenUsage.environmentalImpactHintLine, + textFont: .footnote) + if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -621,6 +668,12 @@ struct UsageMenuCardCostSectionView: View { .font(.caption) Text(tokenUsage.monthLine) .font(.caption) + + EnvironmentalImpactSectionView( + lines: tokenUsage.environmentalImpactLines, + hintLine: tokenUsage.environmentalImpactHintLine, + textFont: .caption) + if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index d6a850c9c..fb3893765 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -192,6 +192,7 @@ struct ProviderDetailView: View { } if self.model.tokenUsage != nil { metricLabels.append(L("Cost")) + metricLabels.append(L("Eco")) } let infoWidth = ProviderSettingsMetrics.labelWidth( @@ -428,6 +429,30 @@ struct ProviderMetricsInlineView: View { title: "", value: tokenUsage.monthLine, labelWidth: self.labelWidth) + if let energySession = tokenUsage.energySessionLine { + ProviderMetricInlineTextRow( + title: L("Eco"), + value: energySession, + labelWidth: self.labelWidth) + } + if let co2Session = tokenUsage.co2SessionLine { + ProviderMetricInlineTextRow( + title: "", + value: co2Session, + labelWidth: self.labelWidth) + } + if let energyMonth = tokenUsage.energyMonthLine { + ProviderMetricInlineTextRow( + title: "", + value: energyMonth, + labelWidth: self.labelWidth) + } + if let co2Month = tokenUsage.co2MonthLine { + ProviderMetricInlineTextRow( + title: "", + value: co2Month, + labelWidth: self.labelWidth) + } } } } diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c0c64d9a9..405be430a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -632,3 +632,12 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimat)"; "cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la teva factura"; + +/* Environmental Footprint */ +"environmental_impact_header" = "Estimació ambiental"; +"environmental_impact_energy_today" = "Avui: %1$@ (%2$d càrregues de mòbil / %3$d bullides de bullidor)"; +"environmental_impact_co2_today" = "CO₂e d'avui: %1$@ (~%2$.1f km conduint)"; +"environmental_impact_energy_month" = "%1$@: %2$@ (%3$d càrregues de mòbil / %4$d bullides de bullidor)"; +"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (~%3$.1f km conduint)"; +"environmental_impact_hint" = "Estimació aproximada a partir dels tokens; l'impacte real varia segons el model, el maquinari i la xarxa elèctrica."; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2ce104e36..a8775058f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -632,3 +632,12 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimated)"; "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; + +/* Environmental Footprint */ +"environmental_impact_header" = "Environmental estimate"; +"environmental_impact_energy_today" = "Today: %1$@ (%2$d phone charges / %3$d kettle boils)"; +"environmental_impact_co2_today" = "Today CO₂e: %1$@ (~%2$.1f km driving)"; +"environmental_impact_energy_month" = "%1$@: %2$@ (%3$d phone charges / %4$d kettle boils)"; +"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (~%3$.1f km driving)"; +"environmental_impact_hint" = "Rough estimate from token count; actual impact varies by model, hardware, and grid mix."; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 01f7c8cb5..616d8dc9f 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -632,3 +632,12 @@ /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; + +/* Environmental Footprint */ +"environmental_impact_header" = "Estimación ambiental"; +"environmental_impact_energy_today" = "Hoy: %1$@ (%2$d cargas de móvil / %3$d hervidos de hervidor)"; +"environmental_impact_co2_today" = "CO₂e de hoy: %1$@ (~%2$.1f km conduciendo)"; +"environmental_impact_energy_month" = "%1$@: %2$@ (%3$d cargas de móvil / %4$d hervidos de hervidor)"; +"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (~%3$.1f km conduciendo)"; +"environmental_impact_hint" = "Estimación aproximada a partir de tokens; el impacto real varía según el modelo, el hardware y la red eléctrica."; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0b93ace52..93cadbfc3 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -632,3 +632,12 @@ /* Cost estimation */ "cost_header_estimated" = "Custo (estimado)"; "cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; + +/* Environmental Footprint */ +"environmental_impact_header" = "Estimativa ambiental"; +"environmental_impact_energy_today" = "Hoje: %1$@ (%2$d cargas de celular / %3$d fervuras de chaleira)"; +"environmental_impact_co2_today" = "CO₂e de hoje: %1$@ (~%2$.1f km dirigindo)"; +"environmental_impact_energy_month" = "%1$@: %2$@ (%3$d cargas de celular / %4$d fervuras de chaleira)"; +"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (~%3$.1f km dirigindo)"; +"environmental_impact_hint" = "Estimativa aproximada a partir dos tokens; o impacto real varia conforme modelo, hardware e matriz elétrica."; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 7e3b6b660..615c10275 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -620,3 +620,12 @@ "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; + +/* Environmental Footprint */ +"environmental_impact_header" = "环境估算"; +"environmental_impact_energy_today" = "今日:%1$@(约 %2$d 次手机充电 / %3$d 次烧水)"; +"environmental_impact_co2_today" = "今日 CO₂e:%1$@(约驾车 %2$.1f 公里)"; +"environmental_impact_energy_month" = "%1$@:%2$@(约 %3$d 次手机充电 / %4$d 次烧水)"; +"environmental_impact_co2_month" = "%1$@ CO₂e:%2$@(约驾车 %3$.1f 公里)"; +"environmental_impact_hint" = "基于 token 数的粗略估算;实际影响会随模型、硬件和电网结构变化。"; +"Eco" = "生态"; diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 90bcaed76..52328436f 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -20,14 +20,30 @@ extension StatusItemController { } static func costMenuTooltipLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { - [ - tokenUsage?.sessionLine, - tokenUsage?.monthLine, - tokenUsage?.hintLine, - tokenUsage?.errorLine, - ] - .compactMap(\.self) - .filter { !$0.isEmpty } + var lines: [String] = [] + if let tokenUsage { + lines.append(tokenUsage.sessionLine) + lines.append(tokenUsage.monthLine) + if let energySession = tokenUsage.energySessionLine { + lines.append(energySession) + } + if let co2Session = tokenUsage.co2SessionLine { + lines.append(co2Session) + } + if let energyMonth = tokenUsage.energyMonthLine { + lines.append(energyMonth) + } + if let co2Month = tokenUsage.co2MonthLine { + lines.append(co2Month) + } + if let hint = tokenUsage.hintLine, !hint.isEmpty { + lines.append(hint) + } + if let error = tokenUsage.errorLine, !error.isEmpty { + lines.append(error) + } + } + return lines.filter { !$0.isEmpty } } static func costMenuVisibleDetailLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { diff --git a/Sources/CodexBarCore/EnvironmentalImpact.swift b/Sources/CodexBarCore/EnvironmentalImpact.swift new file mode 100644 index 000000000..7c7cbc9cb --- /dev/null +++ b/Sources/CodexBarCore/EnvironmentalImpact.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct EnvironmentalImpact: Sendable, Equatable { + private enum Estimate { + static let joulesPerToken = 12.0 + static let joulesPerKWh = 3_600_000.0 + static let globalAverageCO2KgPerKWh = 0.385 + static let smartphoneChargesPerKWh = 75.0 + static let kettleBoilsPerKWh = 10.0 + static let averageCarCO2KgPerKm = 0.12 + } + + public let energyKWh: Double + public let co2Kg: Double + + public init(tokens: Int) { + // Broad estimate only; real usage varies by model, hardware, batching, and grid mix. + let joules = Double(tokens) * Estimate.joulesPerToken + self.energyKWh = joules / Estimate.joulesPerKWh + + self.co2Kg = self.energyKWh * Estimate.globalAverageCO2KgPerKWh + } + + public var smartphoneCharges: Int { + Int(round(self.energyKWh * Estimate.smartphoneChargesPerKWh)) + } + + public var kettleBoils: Int { + Int(round(self.energyKWh * Estimate.kettleBoilsPerKWh)) + } + + public var carKm: Double { + self.co2Kg / Estimate.averageCarCO2KgPerKm + } +} diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index a15437773..a1557dcac 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -288,4 +288,29 @@ public enum UsageFormatter { } return cleaned } + + public static func formatEnergy(_ kwh: Double) -> String { + let wh = kwh * 1000.0 + if wh < 1000.0 { + return "\(self.compactDecimal(wh)) Wh" + } + return "\(self.compactDecimal(kwh)) kWh" + } + + public static func formatCO2(_ kg: Double) -> String { + let g = kg * 1000.0 + if g < 1000.0 { + return "\(self.compactDecimal(g)) g" + } + return "\(self.compactDecimal(kg)) kg" + } + + private static func compactDecimal(_ value: Double) -> String { + if value >= 10.0 { + return String(format: "%.0f", value.rounded()) + } + var formatted = String(format: "%.1f", value) + if formatted.hasSuffix(".0") { formatted.removeLast(2) } + return formatted + } } diff --git a/Tests/CodexBarTests/EnvironmentalImpactTests.swift b/Tests/CodexBarTests/EnvironmentalImpactTests.swift new file mode 100644 index 000000000..d0a3f0a10 --- /dev/null +++ b/Tests/CodexBarTests/EnvironmentalImpactTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct EnvironmentalImpactTests { + @Test + func environmentalImpactCalculations() { + // 100,000 tokens + // Joules = 100,000 * 12 = 1,200,000 J + // kWh = 1,200,000 / 3,600,000 = 0.3333... kWh + // CO2 kg = 0.3333... * 0.385 = 0.12833... kg (128.33... g) + let impact = EnvironmentalImpact(tokens: 100_000) + + #expect(abs(impact.energyKWh - 0.333333) < 0.0001) + #expect(abs(impact.co2Kg - 0.128333) < 0.0001) + + // charges = 0.3333... * 75 = 25 + #expect(impact.smartphoneCharges == 25) + + // kettles = 0.3333... * 10 = 3.33 -> round -> 3 + #expect(impact.kettleBoils == 3) + + // carKm = 0.128333... / 0.12 = 1.0694... + #expect(abs(impact.carKm - 1.0694) < 0.001) + } + + @Test + func energyFormatting() { + // Less than 1 kWh (formatted as Wh) + #expect(UsageFormatter.formatEnergy(0.0015) == "1.5 Wh") + #expect(UsageFormatter.formatEnergy(0.0125) == "13 Wh") + #expect(UsageFormatter.formatEnergy(0.999) == "999 Wh") + + // Greater than or equal to 1 kWh (formatted as kWh) + #expect(UsageFormatter.formatEnergy(1.0) == "1 kWh") + #expect(UsageFormatter.formatEnergy(1.52) == "1.5 kWh") + #expect(UsageFormatter.formatEnergy(12.45) == "12 kWh") + } + + @Test + func cO2Formatting() { + // Less than 1 kg (formatted as g) + #expect(UsageFormatter.formatCO2(0.0015) == "1.5 g") + #expect(UsageFormatter.formatCO2(0.0125) == "13 g") + #expect(UsageFormatter.formatCO2(0.999) == "999 g") + + // Greater than or equal to 1 kg (formatted as kg) + #expect(UsageFormatter.formatCO2(1.0) == "1 kg") + #expect(UsageFormatter.formatCO2(1.52) == "1.5 kg") + #expect(UsageFormatter.formatCO2(12.45) == "12 kg") + } +}