From 0339c4b44ee21a74a426d8d1a93ceb034030cbe5 Mon Sep 17 00:00:00 2001 From: Nikolai Date: Fri, 22 May 2026 13:04:27 +0200 Subject: [PATCH 1/2] Add environmental footprint visualization for token usage --- Sources/CodexBar/MenuCardView+Costs.swift | 45 ++++++++- Sources/CodexBar/MenuCardView.swift | 91 +++++++++++++++++++ .../PreferencesProviderDetailView.swift | 25 +++++ .../Resources/ca.lproj/Localizable.strings | 8 ++ .../Resources/en.lproj/Localizable.strings | 8 ++ .../Resources/es.lproj/Localizable.strings | 8 ++ .../Resources/pt-BR.lproj/Localizable.strings | 8 ++ .../zh-Hans.lproj/Localizable.strings | 9 ++ .../StatusItemController+CostMenuCard.swift | 32 +++++-- .../CodexBarCore/EnvironmentalImpact.swift | 33 +++++++ Sources/CodexBarCore/UsageFormatter.swift | 50 ++++++++++ .../EnvironmentalImpactTests.swift | 53 +++++++++++ 12 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 Sources/CodexBarCore/EnvironmentalImpact.swift create mode 100644 Tests/CodexBarTests/EnvironmentalImpactTests.swift diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 577cc0e37..52ac3feaa 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -56,13 +56,56 @@ 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.boiledKettles) + 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.boiledKettles) + 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) } static func tokenUsageHint(provider: UsageProvider) -> String? { diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 499be19ea..43ec816a7 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,32 @@ struct UsageMenuCardView: View { let hintLine: String? let errorLine: String? let errorCopyText: String? + let energySessionLine: String? + let co2SessionLine: String? + let energyMonthLine: String? + let co2MonthLine: 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) + { + 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 + } } struct ProviderCostSection { @@ -197,6 +224,38 @@ struct UsageMenuCardView: View { .font(.footnote) Text(tokenUsage.monthLine) .font(.footnote) + + if tokenUsage.energySessionLine != nil || tokenUsage.energyMonthLine != nil { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "leaf.fill") + .foregroundColor(.green) + .imageScale(.small) + Text("environmental_impact_header") + .font(.body) + .fontWeight(.medium) + } + .padding(.top, 4) + + if let energySession = tokenUsage.energySessionLine { + Text(energySession) + .font(.footnote) + } + if let co2Session = tokenUsage.co2SessionLine { + Text(co2Session) + .font(.footnote) + } + if let energyMonth = tokenUsage.energyMonthLine { + Text(energyMonth) + .font(.footnote) + } + if let co2Month = tokenUsage.co2MonthLine { + Text(co2Month) + .font(.footnote) + } + } + } + if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -621,6 +680,38 @@ struct UsageMenuCardCostSectionView: View { .font(.caption) Text(tokenUsage.monthLine) .font(.caption) + + if tokenUsage.energySessionLine != nil || tokenUsage.energyMonthLine != nil { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: "leaf.fill") + .foregroundColor(.green) + .imageScale(.small) + Text("environmental_impact_header") + .font(.body) + .fontWeight(.medium) + } + .padding(.top, 4) + + if let energySession = tokenUsage.energySessionLine { + Text(energySession) + .font(.caption) + } + if let co2Session = tokenUsage.co2SessionLine { + Text(co2Session) + .font(.caption) + } + if let energyMonth = tokenUsage.energyMonthLine { + Text(energyMonth) + .font(.caption) + } + if let co2Month = tokenUsage.co2MonthLine { + Text(co2Month) + .font(.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..dd29af857 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -632,3 +632,11 @@ /* 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" = "Petjada ecològica"; +"environmental_impact_energy_today" = "Energia d'avui: %1$@ (%2$d càrregues / %3$d tasses)"; +"environmental_impact_co2_today" = "CO₂e d'avui: %1$@ (%2$.1f km conduint)"; +"environmental_impact_energy_month" = "Energia de %1$@: %2$@ (%3$d càrregues / %4$d tasses)"; +"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km conduint)"; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 2ce104e36..46ef62973 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -632,3 +632,11 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimated)"; "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; + +/* Environmental Footprint */ +"environmental_impact_header" = "Eco Footprint"; +"environmental_impact_energy_today" = "Today Energy: %1$@ (%2$d charges / %3$d cups)"; +"environmental_impact_co2_today" = "Today CO₂e: %1$@ (%2$.1f km driving)"; +"environmental_impact_energy_month" = "%1$@ Energy: %2$@ (%3$d charges / %4$d cups)"; +"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (%3$.1f km driving)"; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 01f7c8cb5..b40677725 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -632,3 +632,11 @@ /* 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" = "Huella ecológica"; +"environmental_impact_energy_today" = "Energía de hoy: %1$@ (%2$d cargas / %3$d tazas)"; +"environmental_impact_co2_today" = "CO₂e de hoy: %1$@ (%2$.1f km conduciendo)"; +"environmental_impact_energy_month" = "Energía de %1$@: %2$@ (%3$d cargas / %4$d tazas)"; +"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km conduciendo)"; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0b93ace52..2df3660c4 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -632,3 +632,11 @@ /* 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" = "Pegada ecológica"; +"environmental_impact_energy_today" = "Energia de hoje: %1$@ (%2$d cargas / %3$d xícaras)"; +"environmental_impact_co2_today" = "CO₂e de hoje: %1$@ (%2$.1f km dirigindo)"; +"environmental_impact_energy_month" = "Energia de %1$@: %2$@ (%3$d cargas / %4$d xícaras)"; +"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km dirigindo)"; +"Eco" = "Eco"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 7e3b6b660..83173760e 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" = "今日二氧化碳排放:%1$@(相当于驾车 %2$.1f 公里)"; +"environmental_impact_energy_month" = "%1$@ 能耗:%2$@(可充手机 %3$d 次 / 烧水 %4$d 杯)"; +"environmental_impact_co2_month" = "%1$@ 二氧化碳排放:%2$@(相当于驾车 %3$.1f 公里)"; +"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..e6e26acb8 --- /dev/null +++ b/Sources/CodexBarCore/EnvironmentalImpact.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct EnvironmentalImpact: Sendable, Equatable { + public let energyKWh: Double + public let co2Kg: Double + + public init(tokens: Int) { + // Based on typical estimates: 12 Joules per token + // Energy in Joules = tokens * 12 + // Energy in kWh = Joules / 3,600,000 + let joules = Double(tokens) * 12.0 + self.energyKWh = joules / 3_600_000.0 + + // Based on global average grid carbon intensity: 385 g CO2e per kWh + self.co2Kg = self.energyKWh * 0.385 + } + + public var smartphoneCharges: Int { + // 1 kWh = ~75 smartphone charges (assuming typical ~13Wh battery and charge efficiency) + Int(round(self.energyKWh * 75.0)) + } + + public var boiledKettles: Int { + // 1 kWh = ~10 boiled kettles of water (assuming 1 liter of water boiled using a 2000W kettle for ~3 minutes) + Int(round(self.energyKWh * 10.0)) + } + + public var carKm: Double { + // ~120g CO2 per km of driving an average gasoline car = 0.12 kg CO2 per km + // km = co2Kg / 0.12 + self.co2Kg / 0.12 + } +} diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index a15437773..87877a363 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -288,4 +288,54 @@ public enum UsageFormatter { } return cleaned } + + public static func formatEnergy(_ kwh: Double) -> String { + let wh = kwh * 1000.0 + if wh < 1000.0 { + let formatted: String + if wh >= 10.0 { + formatted = String(format: "%.0f", wh.rounded()) + } else { + var s = String(format: "%.1f", wh) + if s.hasSuffix(".0") { s.removeLast(2) } + formatted = s + } + return "\(formatted) Wh" + } else { + let formatted: String + if kwh >= 10.0 { + formatted = String(format: "%.0f", kwh.rounded()) + } else { + var s = String(format: "%.1f", kwh) + if s.hasSuffix(".0") { s.removeLast(2) } + formatted = s + } + return "\(formatted) kWh" + } + } + + public static func formatCO2(_ kg: Double) -> String { + let g = kg * 1000.0 + if g < 1000.0 { + let formatted: String + if g >= 10.0 { + formatted = String(format: "%.0f", g.rounded()) + } else { + var s = String(format: "%.1f", g) + if s.hasSuffix(".0") { s.removeLast(2) } + formatted = s + } + return "\(formatted) g" + } else { + let formatted: String + if kg >= 10.0 { + formatted = String(format: "%.0f", kg.rounded()) + } else { + var s = String(format: "%.1f", kg) + if s.hasSuffix(".0") { s.removeLast(2) } + formatted = s + } + return "\(formatted) kg" + } + } } diff --git a/Tests/CodexBarTests/EnvironmentalImpactTests.swift b/Tests/CodexBarTests/EnvironmentalImpactTests.swift new file mode 100644 index 000000000..b69f0a1b6 --- /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.boiledKettles == 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") + } +} From f22d22de5c52eebc32645bbdf205e75b953cbc21 Mon Sep 17 00:00:00 2001 From: Nikolai Date: Fri, 22 May 2026 14:06:07 +0200 Subject: [PATCH 2/2] Polish environmental footprint estimates --- .../EnvironmentalImpactSectionView.swift | 38 +++++++++ Sources/CodexBar/MenuCardView+Costs.swift | 7 +- Sources/CodexBar/MenuCardView.swift | 84 +++++-------------- .../Resources/ca.lproj/Localizable.strings | 11 +-- .../Resources/en.lproj/Localizable.strings | 11 +-- .../Resources/es.lproj/Localizable.strings | 11 +-- .../Resources/pt-BR.lproj/Localizable.strings | 11 +-- .../zh-Hans.lproj/Localizable.strings | 12 +-- .../CodexBarCore/EnvironmentalImpact.swift | 32 +++---- Sources/CodexBarCore/UsageFormatter.swift | 51 +++-------- .../EnvironmentalImpactTests.swift | 2 +- 11 files changed, 126 insertions(+), 144 deletions(-) create mode 100644 Sources/CodexBar/EnvironmentalImpactSectionView.swift 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 52ac3feaa..07cc55457 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -65,7 +65,7 @@ extension UsageMenuCardView.Model { "environmental_impact_energy_today", UsageFormatter.formatEnergy(impact.energyKWh), impact.smartphoneCharges, - impact.boiledKettles) + impact.kettleBoils) co2SessionLine = L( "environmental_impact_co2_today", UsageFormatter.formatCO2(impact.co2Kg), @@ -84,7 +84,7 @@ extension UsageMenuCardView.Model { windowLabel, UsageFormatter.formatEnergy(impact.energyKWh), impact.smartphoneCharges, - impact.boiledKettles) + impact.kettleBoils) co2MonthLine = L( "environmental_impact_co2_month", windowLabel, @@ -105,7 +105,8 @@ extension UsageMenuCardView.Model { energySessionLine: energySessionLine, co2SessionLine: co2SessionLine, energyMonthLine: energyMonthLine, - co2MonthLine: co2MonthLine) + 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 43ec816a7..3782694d1 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -91,6 +91,7 @@ struct UsageMenuCardView: View { let co2SessionLine: String? let energyMonthLine: String? let co2MonthLine: String? + let environmentalImpactHintLine: String? init( sessionLine: String, @@ -101,7 +102,8 @@ struct UsageMenuCardView: View { energySessionLine: String? = nil, co2SessionLine: String? = nil, energyMonthLine: String? = nil, - co2MonthLine: String? = nil) + co2MonthLine: String? = nil, + environmentalImpactHintLine: String? = nil) { self.sessionLine = sessionLine self.monthLine = monthLine @@ -112,6 +114,18 @@ struct UsageMenuCardView: View { 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 } } } @@ -225,36 +239,10 @@ struct UsageMenuCardView: View { Text(tokenUsage.monthLine) .font(.footnote) - if tokenUsage.energySessionLine != nil || tokenUsage.energyMonthLine != nil { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "leaf.fill") - .foregroundColor(.green) - .imageScale(.small) - Text("environmental_impact_header") - .font(.body) - .fontWeight(.medium) - } - .padding(.top, 4) - - if let energySession = tokenUsage.energySessionLine { - Text(energySession) - .font(.footnote) - } - if let co2Session = tokenUsage.co2SessionLine { - Text(co2Session) - .font(.footnote) - } - if let energyMonth = tokenUsage.energyMonthLine { - Text(energyMonth) - .font(.footnote) - } - if let co2Month = tokenUsage.co2MonthLine { - Text(co2Month) - .font(.footnote) - } - } - } + EnvironmentalImpactSectionView( + lines: tokenUsage.environmentalImpactLines, + hintLine: tokenUsage.environmentalImpactHintLine, + textFont: .footnote) if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) @@ -681,36 +669,10 @@ struct UsageMenuCardCostSectionView: View { Text(tokenUsage.monthLine) .font(.caption) - if tokenUsage.energySessionLine != nil || tokenUsage.energyMonthLine != nil { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "leaf.fill") - .foregroundColor(.green) - .imageScale(.small) - Text("environmental_impact_header") - .font(.body) - .fontWeight(.medium) - } - .padding(.top, 4) - - if let energySession = tokenUsage.energySessionLine { - Text(energySession) - .font(.caption) - } - if let co2Session = tokenUsage.co2SessionLine { - Text(co2Session) - .font(.caption) - } - if let energyMonth = tokenUsage.energyMonthLine { - Text(energyMonth) - .font(.caption) - } - if let co2Month = tokenUsage.co2MonthLine { - Text(co2Month) - .font(.caption) - } - } - } + EnvironmentalImpactSectionView( + lines: tokenUsage.environmentalImpactLines, + hintLine: tokenUsage.environmentalImpactHintLine, + textFont: .caption) if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index dd29af857..405be430a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -634,9 +634,10 @@ "cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la teva factura"; /* Environmental Footprint */ -"environmental_impact_header" = "Petjada ecològica"; -"environmental_impact_energy_today" = "Energia d'avui: %1$@ (%2$d càrregues / %3$d tasses)"; -"environmental_impact_co2_today" = "CO₂e d'avui: %1$@ (%2$.1f km conduint)"; -"environmental_impact_energy_month" = "Energia de %1$@: %2$@ (%3$d càrregues / %4$d tasses)"; -"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km conduint)"; +"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 46ef62973..a8775058f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -634,9 +634,10 @@ "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; /* Environmental Footprint */ -"environmental_impact_header" = "Eco Footprint"; -"environmental_impact_energy_today" = "Today Energy: %1$@ (%2$d charges / %3$d cups)"; -"environmental_impact_co2_today" = "Today CO₂e: %1$@ (%2$.1f km driving)"; -"environmental_impact_energy_month" = "%1$@ Energy: %2$@ (%3$d charges / %4$d cups)"; -"environmental_impact_co2_month" = "%1$@ CO₂e: %2$@ (%3$.1f km driving)"; +"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 b40677725..616d8dc9f 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -634,9 +634,10 @@ "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; /* Environmental Footprint */ -"environmental_impact_header" = "Huella ecológica"; -"environmental_impact_energy_today" = "Energía de hoy: %1$@ (%2$d cargas / %3$d tazas)"; -"environmental_impact_co2_today" = "CO₂e de hoy: %1$@ (%2$.1f km conduciendo)"; -"environmental_impact_energy_month" = "Energía de %1$@: %2$@ (%3$d cargas / %4$d tazas)"; -"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km conduciendo)"; +"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 2df3660c4..93cadbfc3 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -634,9 +634,10 @@ "cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; /* Environmental Footprint */ -"environmental_impact_header" = "Pegada ecológica"; -"environmental_impact_energy_today" = "Energia de hoje: %1$@ (%2$d cargas / %3$d xícaras)"; -"environmental_impact_co2_today" = "CO₂e de hoje: %1$@ (%2$.1f km dirigindo)"; -"environmental_impact_energy_month" = "Energia de %1$@: %2$@ (%3$d cargas / %4$d xícaras)"; -"environmental_impact_co2_month" = "CO₂e de %1$@: %2$@ (%3$.1f km dirigindo)"; +"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 83173760e..615c10275 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -622,10 +622,10 @@ "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" = "今日二氧化碳排放:%1$@(相当于驾车 %2$.1f 公里)"; -"environmental_impact_energy_month" = "%1$@ 能耗:%2$@(可充手机 %3$d 次 / 烧水 %4$d 杯)"; -"environmental_impact_co2_month" = "%1$@ 二氧化碳排放:%2$@(相当于驾车 %3$.1f 公里)"; +"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/CodexBarCore/EnvironmentalImpact.swift b/Sources/CodexBarCore/EnvironmentalImpact.swift index e6e26acb8..7c7cbc9cb 100644 --- a/Sources/CodexBarCore/EnvironmentalImpact.swift +++ b/Sources/CodexBarCore/EnvironmentalImpact.swift @@ -1,33 +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) { - // Based on typical estimates: 12 Joules per token - // Energy in Joules = tokens * 12 - // Energy in kWh = Joules / 3,600,000 - let joules = Double(tokens) * 12.0 - self.energyKWh = joules / 3_600_000.0 + // Broad estimate only; real usage varies by model, hardware, batching, and grid mix. + let joules = Double(tokens) * Estimate.joulesPerToken + self.energyKWh = joules / Estimate.joulesPerKWh - // Based on global average grid carbon intensity: 385 g CO2e per kWh - self.co2Kg = self.energyKWh * 0.385 + self.co2Kg = self.energyKWh * Estimate.globalAverageCO2KgPerKWh } public var smartphoneCharges: Int { - // 1 kWh = ~75 smartphone charges (assuming typical ~13Wh battery and charge efficiency) - Int(round(self.energyKWh * 75.0)) + Int(round(self.energyKWh * Estimate.smartphoneChargesPerKWh)) } - public var boiledKettles: Int { - // 1 kWh = ~10 boiled kettles of water (assuming 1 liter of water boiled using a 2000W kettle for ~3 minutes) - Int(round(self.energyKWh * 10.0)) + public var kettleBoils: Int { + Int(round(self.energyKWh * Estimate.kettleBoilsPerKWh)) } public var carKm: Double { - // ~120g CO2 per km of driving an average gasoline car = 0.12 kg CO2 per km - // km = co2Kg / 0.12 - self.co2Kg / 0.12 + self.co2Kg / Estimate.averageCarCO2KgPerKm } } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 87877a363..a1557dcac 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -292,50 +292,25 @@ public enum UsageFormatter { public static func formatEnergy(_ kwh: Double) -> String { let wh = kwh * 1000.0 if wh < 1000.0 { - let formatted: String - if wh >= 10.0 { - formatted = String(format: "%.0f", wh.rounded()) - } else { - var s = String(format: "%.1f", wh) - if s.hasSuffix(".0") { s.removeLast(2) } - formatted = s - } - return "\(formatted) Wh" - } else { - let formatted: String - if kwh >= 10.0 { - formatted = String(format: "%.0f", kwh.rounded()) - } else { - var s = String(format: "%.1f", kwh) - if s.hasSuffix(".0") { s.removeLast(2) } - formatted = s - } - return "\(formatted) kWh" + 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 { - let formatted: String - if g >= 10.0 { - formatted = String(format: "%.0f", g.rounded()) - } else { - var s = String(format: "%.1f", g) - if s.hasSuffix(".0") { s.removeLast(2) } - formatted = s - } - return "\(formatted) g" - } else { - let formatted: String - if kg >= 10.0 { - formatted = String(format: "%.0f", kg.rounded()) - } else { - var s = String(format: "%.1f", kg) - if s.hasSuffix(".0") { s.removeLast(2) } - formatted = s - } - return "\(formatted) kg" + 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 index b69f0a1b6..d0a3f0a10 100644 --- a/Tests/CodexBarTests/EnvironmentalImpactTests.swift +++ b/Tests/CodexBarTests/EnvironmentalImpactTests.swift @@ -19,7 +19,7 @@ struct EnvironmentalImpactTests { #expect(impact.smartphoneCharges == 25) // kettles = 0.3333... * 10 = 3.33 -> round -> 3 - #expect(impact.boiledKettles == 3) + #expect(impact.kettleBoils == 3) // carKm = 0.128333... / 0.12 = 1.0694... #expect(abs(impact.carKm - 1.0694) < 0.001)