diff --git a/.gitignore b/.gitignore
index d44f986f6..4d4b163c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ debug_*.swift
# Misc
.DS_Store
.vscode/
+.out-of-code-insights/
.codex/environments/
.swiftpm-cache/
diff --git a/Package.swift b/Package.swift
index e8e769f33..6dea40135 100644
--- a/Package.swift
+++ b/Package.swift
@@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency =
let package = Package(
name: "CodexBar",
+ defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
diff --git a/README.md b/README.md
index 607d5970e..c66424408 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# CodexBar 🎚️ - May your tokens never run out.
+**English** | [中文](README_zh.md)
+
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
diff --git a/README_zh.md b/README_zh.md
new file mode 100644
index 000000000..a2721483e
--- /dev/null
+++ b/README_zh.md
@@ -0,0 +1,139 @@
+# CodexBar 🎚️ — 愿你的 Token 永不耗尽。
+
+[English](README.md) | **中文**
+
+轻量级 macOS 14+ 菜单栏应用,实时显示 Codex、Claude、Cursor、Gemini、Antigravity、Droid (Factory)、Copilot、z.ai、Kiro、Vertex AI、Augment、Amp、JetBrains AI、OpenRouter、Perplexity 和 Abacus AI 的用量限制(部分提供商支持会话用量 + 每周用量),并显示每个窗口的重置时间。每个提供商对应一个状态项(也可启用"合并图标"模式,通过提供商切换器统一管理,并可选显示最多三个提供商的概览标签页);在设置中按需启用所需提供商。无 Dock 图标,界面极简,菜单栏图标动态变化。
+
+
+
+## 安装
+
+### 系统要求
+- macOS 14+(Sonoma)
+
+### GitHub Releases
+下载地址:
+
+### Homebrew
+```bash
+brew install --cask steipete/tap/codexbar
+```
+
+### Linux(仅 CLI)
+```bash
+brew install steipete/tap/codexbar
+```
+或从 GitHub Releases 下载 `CodexBarCLI-v-linux-.tar.gz`。
+Linux 通过 Omarchy 支持:社区 Waybar 模块和 TUI,由 `codexbar` 可执行文件驱动。
+
+### 首次运行
+- 打开「设置 → 提供商」,启用你需要的提供商。
+- 安装 / 登录你依赖的提供商源(如 `codex`、`claude`、`gemini`、浏览器 Cookie 或 OAuth;Antigravity 需要 Antigravity 应用在运行中)。
+- 可选:「设置 → 提供商 → Codex → OpenAI Cookies」(自动或手动)以获取仪表盘附加信息。
+
+## 提供商
+
+- [Codex](docs/codex.md) — 本地 Codex CLI RPC(+ PTY 回退)及可选 OpenAI 网页仪表盘附加信息。
+- [Claude](docs/claude.md) — OAuth API 或浏览器 Cookie(+ CLI PTY 回退);会话 + 每周用量。
+- [Cursor](docs/cursor.md) — 浏览器会话 Cookie,获取套餐、用量及账单重置信息。
+- [Gemini](docs/gemini.md) — 基于 OAuth 的配额 API,使用 Gemini CLI 凭据(无需浏览器 Cookie)。
+- [Antigravity](docs/antigravity.md) — 本地语言服务器探测(实验性);无需外部认证。
+- [Droid](docs/factory.md) — 浏览器 Cookie + WorkOS Token 流,获取 Factory 用量及账单信息。
+- [Copilot](docs/copilot.md) — GitHub 设备流 + Copilot 内部用量 API。
+- [z.ai](docs/zai.md) — API Token(Keychain)用于配额 + MCP 窗口。
+- [Kimi](docs/kimi.md) — 认证 Token(来自 `kimi-auth` Cookie 的 JWT)用于每周配额 + 5 小时速率限制。
+- [Kimi K2](docs/kimi-k2.md) — API Key 用于基于额度的用量统计。
+- [Kiro](docs/kiro.md) — 通过 `kiro-cli /usage` 命令获取 CLI 用量;每月点数 + 奖励点数。
+- [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth,通过本地 Claude 日志追踪 Token 费用。
+- [Augment](docs/augment.md) — 基于浏览器 Cookie 的认证,自动保持会话活跃;点数追踪与用量监控。
+- [Amp](docs/amp.md) — 基于浏览器 Cookie 的认证,追踪 Amp Free 用量。
+- [JetBrains AI](docs/jetbrains.md) — 从 JetBrains IDE 配置读取本地 XML 配额;每月点数追踪。
+- [OpenRouter](docs/openrouter.md) — API Token 用于跨多个 AI 提供商的额度用量追踪。
+- [Abacus AI](docs/abacus.md) — 浏览器 Cookie 认证,追踪 ChatLLM/RouteLLM 计算点数。
+- [DeepSeek](docs/deepseek.md) — API Key 用于余额追踪(付费余额 vs 赠送余额)。
+- 欢迎新提供商:[提供商开发指南](docs/provider.md)。
+
+## 图标与截图
+菜单栏图标是一个迷你双条进度计:
+- **上条**:5 小时 / 会话窗口。若每周用量缺失或已耗尽但有可用点数,则变为较粗的点数条。
+- **下条**:每周窗口(细线)。
+- 错误 / 数据过期时图标变暗;状态叠加层指示异常事件。
+
+## 功能特性
+- 多提供商菜单栏,每个提供商可单独开关(设置 → 提供商)。
+- 会话 + 每周用量计量器,附重置倒计时。
+- 可选 Codex 网页仪表盘增强(剩余代码审查次数、用量分解、积分历史)。
+- Codex + Claude 本地费用扫描(最近 30 天)。
+- 提供商状态轮询,菜单和图标叠加层显示事件徽章。
+- 合并图标模式:将多个提供商合并为一个状态项 + 切换器,可选显示最多三个提供商的概览标签页。
+- 刷新频率预设(手动、1 分钟、2 分钟、5 分钟、15 分钟)。
+- 内置 CLI(`codexbar`)支持脚本和 CI 使用(含 `codexbar cost --provider codex|claude` 本地费用统计);提供 Linux CLI 构建。
+- WidgetKit 小组件镜像菜单卡片快照。
+- 隐私优先:默认在设备本地解析;浏览器 Cookie 为可选功能,复用现有 Cookie(不存储密码)。
+
+## 隐私说明
+CodexBar 不会扫描你的磁盘——它不会爬取整个文件系统,仅在相关功能启用时读取少量已知位置(浏览器 Cookie / 本地存储、本地 JSONL 日志)。详见 [issue #12](https://github.com/steipete/CodexBar/issues/12) 中的讨论和审计说明。
+
+## macOS 权限说明
+- **完全磁盘访问(可选)**:仅在读取 Safari Cookie / 本地存储以用于网页端提供商(Codex Web、Claude Web、Cursor、Droid/Factory)时需要。若不授予,请改用 Chrome/Firefox Cookie 或仅使用 CLI 来源。
+- **钥匙串访问(由 macOS 提示)**:
+ - Chrome Cookie 导入需要「Chrome Safe Storage」密钥来解密 Cookie。
+ - 若存在由 Claude CLI 写入的 Claude OAuth 凭据,CodexBar 会从钥匙串中读取。
+ - z.ai API Token 通过「偏好设置 → 提供商」存储至钥匙串;Copilot 在设备流程中将 API Token 存入钥匙串。
+ - **如何避免钥匙串弹窗?**
+ - 打开「钥匙串访问.app」→ 登录钥匙串 → 搜索对应条目(如「Claude Code-credentials」)。
+ - 打开该条目 → **访问控制** → 在「始终允许访问此项目的应用程序」中添加 `CodexBar.app`。
+ - 建议仅添加 CodexBar(避免使用「允许所有应用程序」,除非你希望完全开放)。
+ - 保存后重新启动 CodexBar。
+ - 参考截图:
+ - **浏览器同理**:
+ - 找到浏览器的「Safe Storage」条目(如「Chrome Safe Storage」、「Brave Safe Storage」、「Firefox」、「Microsoft Edge Safe Storage」)。
+ - 打开该条目 → **访问控制** → 添加 `CodexBar.app`。
+ - 此后 CodexBar 解密该浏览器的 Cookie 时将不再弹出提示。
+- **文件与文件夹提示(文件夹 / 卷访问)**:CodexBar 会启动提供商 CLI(codex/claude/gemini/antigravity)。若这些 CLI 读取项目目录或外置硬盘,macOS 可能会向 CodexBar 请求该文件夹 / 卷的访问权限(如桌面或外置硬盘)。这由 CLI 的工作目录触发,而非后台磁盘扫描。
+- **不会申请的权限**:不需要屏幕录制、辅助功能或自动化权限;不存储密码(选择启用时复用浏览器 Cookie)。
+
+## 文档
+- 提供商概览:[docs/providers.md](docs/providers.md)
+- 提供商开发指南:[docs/provider.md](docs/provider.md)
+- Issue 标签指南:[docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md)
+- UI 与图标说明:[docs/ui.md](docs/ui.md)
+- CLI 参考:[docs/cli.md](docs/cli.md)
+- 架构说明:[docs/architecture.md](docs/architecture.md)
+- 刷新循环:[docs/refresh-loop.md](docs/refresh-loop.md)
+- 状态轮询:[docs/status.md](docs/status.md)
+- Sparkle 更新:[docs/sparkle.md](docs/sparkle.md)
+- 发布清单:[docs/RELEASING.md](docs/RELEASING.md)
+
+## 开发入门
+- Clone 仓库后用 Xcode 打开,或直接运行脚本。
+- 首次启动后,在「设置 → 提供商」中开启相应提供商。
+- 安装 / 登录你依赖的提供商源(CLI、浏览器 Cookie 或 OAuth)。
+- 可选:设置 OpenAI Cookie(自动或手动)以获取 Codex 仪表盘附加信息。
+
+## 从源码构建
+```bash
+swift build -c release # 发布版;debug 用于开发
+./Scripts/package_app.sh # 原地构建 CodexBar.app
+CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh # 临时签名(无需 Apple 开发者账号)
+open CodexBar.app
+```
+
+开发循环:
+```bash
+./Scripts/compile_and_run.sh
+```
+
+## 相关项目
+- ✂️ [Trimmy](https://github.com/steipete/Trimmy) — "粘贴一次,运行一次。" 将多行 Shell 片段展平,使其可一键粘贴并运行。
+- 🧳 [MCPorter](https://mcporter.dev) — 用于 Model Context Protocol 服务器的 TypeScript 工具包 + CLI。
+- 🧿 [oracle](https://askoracle.dev) — 卡住时向 Oracle 求助。以自定义上下文和文件调用 GPT-5 Pro。
+
+## 寻找 Windows 版本?
+- [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar)
+
+## 致谢
+灵感来源于 [ccusage](https://github.com/ryoppippi/ccusage)(MIT),尤其是费用用量追踪功能。
+
+## 许可证
+MIT • Peter Steinberger ([steipete](https://twitter.com/steipete))
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index ac8842dab..077fe55ed 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -269,6 +269,8 @@ cat > "$APP/Contents/Info.plist" <CFBundlePackageTypeAPPL
CFBundleShortVersionString${MARKETING_VERSION}
CFBundleVersion${BUILD_NUMBER}
+ CFBundleDevelopmentRegionen
+ CFBundleLocalizationsenzh-Hans
LSMinimumSystemVersion14.0
LSUIElement
CFBundleIconFileIcon
@@ -307,6 +309,20 @@ resolve_binary_path() {
fi
}
+resolve_framework_path() {
+ local name="$1"
+ local arch="$2"
+ local candidate
+ candidate=$(build_product_path "$name.framework" "$arch")
+ if [[ -d "$candidate" ]]; then
+ echo "$candidate"
+ return
+ fi
+ if [[ "$arch" == "arm64" || "$arch" == "x86_64" ]] && [[ -d ".build/$CONF/$name.framework" ]]; then
+ echo ".build/$CONF/$name.framework"
+ fi
+}
+
verify_binary_arches() {
local binary="$1"; shift
local expected=("$@")
@@ -386,13 +402,7 @@ PLIST
install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget"
generate_widget_appintents_metadata "$WIDGET_APP/Contents/Resources"
fi
-# Embed Sparkle.framework
-if [[ -d ".build/$CONF/Sparkle.framework" ]]; then
- cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/"
- chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework"
- install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar"
- # Re-sign Sparkle and all nested components with Developer ID + timestamp
- SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
+
if [[ "$SIGNING_MODE" == "adhoc" ]]; then
CODESIGN_ID="-"
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
@@ -403,7 +413,16 @@ else
CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
fi
-function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
+
+# Embed Sparkle.framework
+SPARKLE_SOURCE="$(resolve_framework_path "Sparkle" "${ARCH_LIST[0]}")"
+if [[ -n "${SPARKLE_SOURCE:-}" && -d "$SPARKLE_SOURCE" ]]; then
+ cp -R "$SPARKLE_SOURCE" "$APP/Contents/Frameworks/"
+ chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework"
+ install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar"
+ # Re-sign Sparkle and all nested components with Developer ID + timestamp
+ SPARKLE="$APP/Contents/Frameworks/Sparkle.framework"
+ function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
# Sign innermost binaries first, then the framework root to seal resources
resign "$SPARKLE"
resign "$SPARKLE/Versions/B/Sparkle"
@@ -421,16 +440,26 @@ fi
if [[ -f "$ICON_TARGET" ]]; then
cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns"
fi
+if [[ -f "$ROOT/Sources/CodexBar/Resources/Icon-classic.icns" ]]; then
+ cp "$ROOT/Sources/CodexBar/Resources/Icon-classic.icns" "$APP/Contents/Resources/Icon-classic.icns"
+fi
# Bundle app resources (provider icons, etc.).
APP_RESOURCES_DIR="$ROOT/Sources/CodexBar/Resources"
if [[ -d "$APP_RESOURCES_DIR" ]]; then
cp -R "$APP_RESOURCES_DIR/." "$APP/Contents/Resources/"
fi
+if [[ -f "$ICON_TARGET" ]]; then
+ cp "$ICON_TARGET" "$APP/Contents/Resources/Icon.icns"
+fi
if [[ ! -f "$APP/Contents/Resources/Icon-classic.icns" ]]; then
echo "ERROR: Missing Icon-classic.icns in app bundle resources." >&2
exit 1
fi
+if [[ ! -f "$APP/Contents/Resources/Icon.icns" ]]; then
+ echo "ERROR: Missing Icon.icns in app bundle resources." >&2
+ exit 1
+fi
# SwiftPM resource bundles (e.g. KeyboardShortcuts) are emitted next to the built binary.
CODEXBAR_BINARY="$(resolve_binary_path "CodexBar" "${ARCH_LIST[0]}")"
diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift
index 7356f9671..381a5f612 100644
--- a/Sources/CodexBar/Date+RelativeDescription.swift
+++ b/Sources/CodexBar/Date+RelativeDescription.swift
@@ -14,7 +14,7 @@ extension Date {
func relativeDescription(now: Date = .now) -> String {
let seconds = abs(now.timeIntervalSince(self))
if seconds < 15 {
- return "just now"
+ return NSLocalizedString("time.just_now", comment: "")
}
return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now)
}
diff --git a/Sources/CodexBar/LoadingPattern.swift b/Sources/CodexBar/LoadingPattern.swift
index a32fbe406..b24c4ec40 100644
--- a/Sources/CodexBar/LoadingPattern.swift
+++ b/Sources/CodexBar/LoadingPattern.swift
@@ -14,12 +14,12 @@ enum LoadingPattern: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
- case .knightRider: "Knight Rider"
- case .cylon: "Cylon"
- case .outsideIn: "Outside-In"
- case .race: "Race"
- case .pulse: "Pulse"
- case .unbraid: "Unbraid (logo → bars)"
+ case .knightRider: localizedUI("Knight Rider")
+ case .cylon: localizedUI("Cylon")
+ case .outsideIn: localizedUI("Outside-In")
+ case .race: localizedUI("Race")
+ case .pulse: localizedUI("Pulse")
+ case .unbraid: localizedUI("Unbraid (logo → bars)")
}
}
diff --git a/Sources/CodexBar/LocalizationSupport.swift b/Sources/CodexBar/LocalizationSupport.swift
new file mode 100644
index 000000000..e8411c961
--- /dev/null
+++ b/Sources/CodexBar/LocalizationSupport.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+@inline(__always)
+func localizedUI(_ text: String) -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return text }
+ return NSLocalizedString(text, comment: "")
+}
+
+@inline(__always)
+func localizedUIFormat(_ key: String, _ args: CVarArg...) -> String {
+ String(format: NSLocalizedString(key, comment: ""), arguments: args)
+}
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 02ab642c6..bea1f9d61 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -4,6 +4,10 @@ import SwiftUI
/// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels.
struct UsageMenuCardView: View {
+ private static func localizedMetricTitle(_ title: String) -> String {
+ NSLocalizedString(title, comment: "")
+ }
+
struct Model {
enum PercentStyle: String {
case left
@@ -11,8 +15,8 @@ struct UsageMenuCardView: View {
var labelSuffix: String {
switch self {
- case .left: "left"
- case .used: "used"
+ case .left: NSLocalizedString("usage.percent.left", comment: "")
+ case .used: NSLocalizedString("usage.percent.used", comment: "")
}
}
@@ -112,9 +116,9 @@ struct UsageMenuCardView: View {
static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {
if provider == .openrouter, metric.id == "primary" {
- return "API key limit"
+ return NSLocalizedString("API key limit", comment: "")
}
- return metric.title
+ return Self.localizedMetricTitle(metric.title)
}
var body: some View {
@@ -177,7 +181,7 @@ struct UsageMenuCardView: View {
}
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
- Text("Cost")
+ Text(localizedUI("Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
@@ -310,7 +314,7 @@ private struct CopyIconButton: View {
.frame(width: 18, height: 18)
}
.buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted))
- .accessibilityLabel(self.didCopy ? "Copied" : "Copy error")
+ .accessibilityLabel(localizedUI(self.didCopy ? "Copied" : "Copy error"))
}
private func copyToPasteboard() {
@@ -333,12 +337,12 @@ private struct ProviderCostContent: View {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
- accessibilityLabel: "Extra usage spent")
+ accessibilityLabel: localizedUI("Extra usage spent"))
HStack(alignment: .firstTextBaseline) {
Text(self.section.spendLine)
.font(.footnote)
Spacer()
- Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed))))
+ Text(localizedUIFormat("%.0f%% used", min(100, max(0, self.section.percentUsed))))
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
@@ -420,7 +424,7 @@ private struct UsageNotesContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in
- Text(note)
+ Text(localizedUI(note))
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(2)
@@ -464,7 +468,7 @@ struct UsageMenuCardUsageSectionView: View {
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
- Text(placeholder)
+ Text(localizedUI(placeholder))
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
}
@@ -542,18 +546,19 @@ private struct CreditsBarContent: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Credits")
+ .help(localizedUI("Credits"))
.font(.body)
.fontWeight(.medium)
if let percentLeft {
UsageProgressBar(
percent: percentLeft,
tint: self.progressColor,
- accessibilityLabel: "Credits remaining")
+ accessibilityLabel: localizedUI("Credits remaining"))
HStack(alignment: .firstTextBaseline) {
Text(self.creditsText)
.font(.caption)
Spacer()
- Text(self.scaleText)
+ Text(localizedUIFormat("%@ tokens", self.scaleText.replacingOccurrences(of: " tokens", with: "")))
.font(.caption)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
@@ -589,7 +594,7 @@ struct UsageMenuCardCostSectionView: View {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
- Text("Cost")
+ Text(localizedUI("Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
@@ -1379,20 +1384,20 @@ extension UsageMenuCardView.Model {
else { return nil }
let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
- let resetText = "Regenerates \(countdown)"
+ let resetText = localizedUIFormat("usage.synthetic.regenerates", countdown)
let nextRegenPercent = (nextRegenAmount / cost.limit) * 100
let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent)
let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
- let suffix = showUsed ? "used after next regen" : "after next regen"
+ let suffix = localizedUI(showUsed ? "used after next regen" : "after next regen")
let ticksToFull = max(0, cost.used) / nextRegenAmount
let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
let right = if ticksToFull <= 0.1 {
- "Near full"
+ localizedUI("Near full")
} else if ticksToFull < 1.5 {
- "Full in ~1 regen"
+ localizedUI("Full in ~1 regen")
} else {
- String(format: "Full in ~%.0f regens", ceil(ticksToFull))
+ localizedUIFormat("Full in ~%.0f regens", ceil(ticksToFull))
}
return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
}
@@ -1408,21 +1413,21 @@ extension UsageMenuCardView.Model {
else { return nil }
let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
- let resetText = "Regenerates \(countdown)"
+ let resetText = localizedUIFormat("usage.synthetic.regenerates", countdown)
let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent)
let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
- let suffix = showUsed ? "used after next regen" : "after next regen"
+ let suffix = localizedUI(showUsed ? "used after next regen" : "after next regen")
let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
let missingPercent = max(0, window.usedPercent)
let ticksToFull = missingPercent / nextRegenPercent
let right = if ticksToFull <= 0.1 {
- "Near full"
+ localizedUI("Near full")
} else if ticksToFull < 1.5 {
- "Full in ~1 regen"
+ localizedUI("Full in ~1 regen")
} else {
- String(format: "Full in ~%.0f regens", ceil(ticksToFull))
+ localizedUIFormat("Full in ~%.0f regens", ceil(ticksToFull))
}
return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
@@ -1462,9 +1467,9 @@ extension UsageMenuCardView.Model {
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let sessionLine: String = {
if let sessionTokens {
- return "Today: \(sessionCost) · \(sessionTokens) tokens"
+ return localizedUIFormat("usage.cost.today_tokens", sessionCost, sessionTokens)
}
- return "Today: \(sessionCost)"
+ return localizedUIFormat("usage.cost.today", sessionCost)
}()
let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
@@ -1473,9 +1478,9 @@ extension UsageMenuCardView.Model {
let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
let monthLine: String = {
if let monthTokens {
- return "Last 30 days: \(monthCost) · \(monthTokens) tokens"
+ return localizedUIFormat("usage.cost.last_30_days_tokens", monthCost, monthTokens)
}
- return "Last 30 days: \(monthCost)"
+ return localizedUIFormat("usage.cost.last_30_days", monthCost)
}()
let err = (error?.isEmpty ?? true) ? nil : error
return TokenUsageSection(
@@ -1499,17 +1504,17 @@ extension UsageMenuCardView.Model {
let title: String
if cost.currencyCode == "Quota" {
- title = "Quota usage"
+ title = localizedUI("Quota usage")
used = String(format: "%.0f", cost.used)
limit = String(format: "%.0f", cost.limit)
} else {
- title = "Extra usage"
+ title = localizedUI("Extra usage")
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
}
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
- let periodLabel = cost.period ?? "This month"
+ let periodLabel = localizedUI(cost.period ?? "This month")
return ProviderCostSection(
title: title,
diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
index 7112b50ce..b4fbf6d60 100644
--- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
+++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
@@ -582,13 +582,13 @@ struct PlanUtilizationHistoryChartMenuView: View {
{
switch name {
case .session:
- metadata?.sessionLabel ?? "Session"
+ localizedUI(metadata?.sessionLabel ?? "Session")
case .weekly:
- metadata?.weeklyLabel ?? "Weekly"
+ localizedUI(metadata?.weeklyLabel ?? "Weekly")
case .opus:
- metadata?.opusLabel ?? "Opus"
+ localizedUI(metadata?.opusLabel ?? "Opus")
default:
- self.fallbackTitle(for: name.rawValue)
+ localizedUI(self.fallbackTitle(for: name.rawValue))
}
}
@@ -614,9 +614,9 @@ struct PlanUtilizationHistoryChartMenuView: View {
private nonisolated static func emptyStateText(title: String?) -> String {
if let title {
- return "No \(title.lowercased()) utilization data yet."
+ return localizedUIFormat("history.empty_state.for_title", title.lowercased())
}
- return "No utilization data yet."
+ return localizedUI("history.empty_state")
}
#if DEBUG
@@ -780,19 +780,17 @@ extension PlanUtilizationHistoryChartMenuView {
let used = max(0, min(100, point.usedPercent))
if !point.isObserved {
- return "\(dateLabel): -"
+ return localizedUIFormat("history.detail_line.unobserved", dateLabel)
}
let usedText = used.formatted(.number.precision(.fractionLength(0...1)))
- return "\(dateLabel): \(usedText)% used"
+ return localizedUIFormat("history.detail_line.used", dateLabel, usedText)
}
private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String {
let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.locale = Locale.autoupdatingCurrent
formatter.timeZone = TimeZone.current
- formatter.amSymbol = "am"
- formatter.pmSymbol = "pm"
- formatter.dateFormat = "MMM d, h:mm a"
+ formatter.setLocalizedDateFormatFromTemplate(windowMinutes <= 300 ? "MMM d HH:mm" : "MMM d HH:mm")
return formatter.string(from: date)
}
}
diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift
index 9b901c9d8..1ddd75a31 100644
--- a/Sources/CodexBar/PreferencesAdvancedPane.swift
+++ b/Sources/CodexBar/PreferencesAdvancedPane.swift
@@ -11,17 +11,17 @@ struct AdvancedPane: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
SettingsSection(contentSpacing: 8) {
- Text("Keyboard shortcut")
+ Text(localizedUI("Keyboard shortcut"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
HStack(alignment: .center, spacing: 12) {
- Text("Open menu")
+ Text(localizedUI("Open menu"))
.font(.body)
Spacer()
KeyboardShortcuts.Recorder(for: .openMenu)
}
- Text("Trigger the menu bar menu from anywhere.")
+ Text(localizedUI("Trigger the menu bar menu from anywhere."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -36,7 +36,7 @@ struct AdvancedPane: View {
if self.isInstallingCLI {
ProgressView().controlSize(.small)
} else {
- Text("Install CLI")
+ Text(localizedUI("Install CLI"))
}
}
.disabled(self.isInstallingCLI)
@@ -48,7 +48,7 @@ struct AdvancedPane: View {
.lineLimit(2)
}
}
- Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")
+ Text(localizedUI("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -57,16 +57,16 @@ struct AdvancedPane: View {
SettingsSection(contentSpacing: 10) {
PreferenceToggleRow(
- title: "Show Debug Settings",
- subtitle: "Expose troubleshooting tools in the Debug tab.",
+ title: localizedUI("Show Debug Settings"),
+ subtitle: localizedUI("Expose troubleshooting tools in the Debug tab."),
binding: self.$settings.debugMenuEnabled)
PreferenceToggleRow(
- title: "Surprise me",
- subtitle: "Check if you like your agents having some fun up there.",
+ title: localizedUI("Surprise me"),
+ subtitle: localizedUI("Check if you like your agents having some fun up there."),
binding: self.$settings.randomBlinkEnabled)
PreferenceToggleRow(
- title: "Weekly limit confetti",
- subtitle: "Play full-screen confetti when weekly usage resets.",
+ title: localizedUI("Weekly limit confetti"),
+ subtitle: localizedUI("Play full-screen confetti when weekly usage resets."),
binding: self.$settings.confettiOnWeeklyLimitResetsEnabled)
}
@@ -74,22 +74,21 @@ struct AdvancedPane: View {
SettingsSection(contentSpacing: 10) {
PreferenceToggleRow(
- title: "Hide personal information",
- subtitle: "Obscure email addresses in the menu bar and menu UI.",
+ title: localizedUI("Hide personal information"),
+ subtitle: localizedUI("Obscure email addresses in the menu bar and menu UI."),
binding: self.$settings.hidePersonalInfo)
}
Divider()
SettingsSection(
- title: "Keychain access",
- caption: """
- Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \
- headers manually in Providers.
- """) {
+ title: localizedUI("Keychain access"),
+ caption: localizedUI(
+ "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."))
+ {
PreferenceToggleRow(
- title: "Disable Keychain access",
- subtitle: "Prevents any Keychain access while enabled.",
+ title: localizedUI("Disable Keychain access"),
+ subtitle: localizedUI("Prevents any Keychain access while enabled."),
binding: self.$settings.debugDisableKeychainAccess)
}
}
@@ -109,7 +108,7 @@ extension AdvancedPane {
let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI")
let fm = FileManager.default
guard fm.fileExists(atPath: helperURL.path) else {
- self.cliStatus = "CodexBarCLI not found in app bundle."
+ self.cliStatus = localizedUI("CodexBarCLI not found in app bundle.")
return
}
@@ -123,29 +122,29 @@ extension AdvancedPane {
let dir = (dest as NSString).deletingLastPathComponent
guard fm.fileExists(atPath: dir) else { continue }
guard fm.isWritableFile(atPath: dir) else {
- results.append("No write access: \(dir)")
+ results.append(localizedUIFormat("No write access: %@", dir))
continue
}
if fm.fileExists(atPath: dest) {
if Self.isLink(atPath: dest, pointingTo: helperURL.path) {
- results.append("Installed: \(dir)")
+ results.append(localizedUIFormat("Installed: %@", dir))
} else {
- results.append("Exists: \(dir)")
+ results.append(localizedUIFormat("Exists: %@", dir))
}
continue
}
do {
try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path)
- results.append("Installed: \(dir)")
+ results.append(localizedUIFormat("Installed: %@", dir))
} catch {
- results.append("Failed: \(dir)")
+ results.append(localizedUIFormat("Failed: %@", dir))
}
}
self.cliStatus = results.isEmpty
- ? "No writable bin dirs found."
+ ? localizedUI("No writable bin dirs found.")
: results.joined(separator: " · ")
}
diff --git a/Sources/CodexBar/PreferencesComponents.swift b/Sources/CodexBar/PreferencesComponents.swift
index d0fb56a0d..b68f78579 100644
--- a/Sources/CodexBar/PreferencesComponents.swift
+++ b/Sources/CodexBar/PreferencesComponents.swift
@@ -10,13 +10,13 @@ struct PreferenceToggleRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 5.4) {
Toggle(isOn: self.$binding) {
- Text(self.title)
+ Text(localizedUI(self.title))
.font(.body)
}
.toggleStyle(.checkbox)
if let subtitle, !subtitle.isEmpty {
- Text(subtitle)
+ Text(localizedUI(subtitle))
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -47,11 +47,11 @@ struct SettingsSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if let title, !title.isEmpty {
- Text(title)
+ Text(localizedUI(title))
.font(.subheadline.weight(.semibold))
}
if let caption {
- Text(caption)
+ Text(localizedUI(caption))
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -77,7 +77,7 @@ struct AboutLinkRow: View {
} label: {
HStack(spacing: 8) {
Image(systemName: self.icon)
- Text(self.title)
+ Text(localizedUI(self.title))
.underline(self.hovering, color: .accentColor)
}
.frame(maxWidth: .infinity)
diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift
index 39a95a55f..7d84717c4 100644
--- a/Sources/CodexBar/PreferencesGeneralPane.swift
+++ b/Sources/CodexBar/PreferencesGeneralPane.swift
@@ -11,20 +11,20 @@ struct GeneralPane: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
SettingsSection(contentSpacing: 12) {
- Text("System")
+ Text("pref.general.section.system")
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
PreferenceToggleRow(
- title: "Start at Login",
- subtitle: "Automatically opens CodexBar when you start your Mac.",
+ title: String(localized: "pref.general.launch_at_login.title"),
+ subtitle: String(localized: "pref.general.launch_at_login.subtitle"),
binding: self.$settings.launchAtLogin)
}
Divider()
SettingsSection(contentSpacing: 12) {
- Text("Usage")
+ Text("pref.general.section.usage")
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
@@ -32,18 +32,18 @@ struct GeneralPane: View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: self.$settings.costUsageEnabled) {
- Text("Show cost summary")
+ Text("pref.general.cost_summary.title")
.font(.body)
}
.toggleStyle(.checkbox)
- Text("Reads local usage logs. Shows today + last 30 days cost in the menu.")
+ Text("pref.general.cost_summary.subtitle")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
if self.settings.costUsageEnabled {
- Text("Auto-refresh: hourly · Timeout: 10m")
+ Text("pref.general.auto_refresh_info")
.font(.footnote)
.foregroundStyle(.tertiary)
@@ -57,21 +57,21 @@ struct GeneralPane: View {
Divider()
SettingsSection(contentSpacing: 12) {
- Text("Automation")
+ Text("pref.general.section.automation")
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
- Text("Refresh cadence")
+ Text("pref.general.refresh_cadence.title")
.font(.body)
- Text("How often CodexBar polls providers in the background.")
+ Text("pref.general.refresh_cadence.subtitle")
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
- Picker("Refresh cadence", selection: self.$settings.refreshFrequency) {
+ Picker("pref.general.refresh_cadence.title", selection: self.$settings.refreshFrequency) {
ForEach(RefreshFrequency.allCases) { option in
Text(option.label).tag(option)
}
@@ -81,29 +81,54 @@ struct GeneralPane: View {
.frame(maxWidth: 200)
}
if self.settings.refreshFrequency == .manual {
- Text("Auto-refresh is off; use the menu's Refresh command.")
+ Text("pref.general.refresh_cadence.manual_note")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
PreferenceToggleRow(
- title: "Check provider status",
- subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " +
- "Gemini/Antigravity, surfacing incidents in the icon and menu.",
+ title: String(localized: "pref.general.provider_status.title"),
+ subtitle: String(localized: "pref.general.provider_status.subtitle"),
binding: self.$settings.statusChecksEnabled)
PreferenceToggleRow(
- title: "Session quota notifications",
- subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " +
- "available again.",
+ title: String(localized: "pref.general.session_notifications.title"),
+ subtitle: String(localized: "pref.general.session_notifications.subtitle"),
binding: self.$settings.sessionQuotaNotificationsEnabled)
}
Divider()
+ SettingsSection(contentSpacing: 12) {
+ Text("pref.general.section.language")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("pref.general.app_language.title")
+ .font(.body)
+ Text("pref.general.app_language.subtitle")
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ }
+ Spacer()
+ Picker("pref.general.app_language.title", selection: self.$settings.appLanguage) {
+ ForEach(AppLanguage.allCases) { lang in
+ Text(lang.label).tag(lang)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(width: 140)
+ }
+ }
+
+ Divider()
+
SettingsSection(contentSpacing: 12) {
HStack {
Spacer()
- Button("Quit CodexBar") { NSApp.terminate(nil) }
+ Button("pref.general.quit") { NSApp.terminate(nil) }
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
@@ -119,7 +144,7 @@ struct GeneralPane: View {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
guard provider == .claude || provider == .codex else {
- return Text("\(name): unsupported")
+ return Text(String(format: String(localized: "pref.general.cost.unsupported"), name))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -133,14 +158,14 @@ struct GeneralPane: View {
formatter.unitsStyle = .abbreviated
return formatter.string(from: seconds).map { " (\($0))" } ?? ""
}()
- return Text("\(name): fetching…\(elapsed)")
+ return Text(String(format: String(localized: "pref.general.cost.fetching"), name, elapsed))
.font(.footnote)
.foregroundStyle(.tertiary)
}
if let snapshot = self.store.tokenSnapshot(for: provider) {
let updated = UsageFormatter.updatedString(from: snapshot.updatedAt)
let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
- return Text("\(name): \(updated) · 30d \(cost)")
+ return Text(String(format: String(localized: "pref.general.cost.updated"), name, updated, cost))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -154,11 +179,11 @@ struct GeneralPane: View {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
let when = rel.localizedString(for: lastAttempt, relativeTo: Date())
- return Text("\(name): last attempt \(when)")
+ return Text(String(format: String(localized: "pref.general.cost.last_attempt"), name, when))
.font(.footnote)
.foregroundStyle(.tertiary)
}
- return Text("\(name): no data yet")
+ return Text(String(format: String(localized: "pref.general.cost.no_data"), name))
.font(.footnote)
.foregroundStyle(.tertiary)
}
diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift
index 5ecff079d..5201e757c 100644
--- a/Sources/CodexBar/PreferencesProviderDetailView.swift
+++ b/Sources/CodexBar/PreferencesProviderDetailView.swift
@@ -64,7 +64,7 @@ struct ProviderDetailView: View {
return nil
}
guard provider == .openrouter else {
- return (label: "Plan", value: rawPlan)
+ return (label: localizedUI("Plan"), value: rawPlan)
}
let prefix = "Balance:"
@@ -72,10 +72,10 @@ struct ProviderDetailView: View {
let valueStart = rawPlan.index(rawPlan.startIndex, offsetBy: prefix.count)
let trimmedValue = rawPlan[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedValue.isEmpty {
- return (label: "Balance", value: trimmedValue)
+ return (label: localizedUI("Balance"), value: trimmedValue)
}
}
- return (label: "Balance", value: rawPlan)
+ return (label: localizedUI("Balance"), value: rawPlan)
}
var body: some View {
@@ -99,7 +99,7 @@ struct ProviderDetailView: View {
if let errorDisplay {
ProviderErrorView(
- title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:",
+ title: localizedUIFormat("Last %@ fetch failed:", self.store.metadata(for: self.provider).displayName),
display: errorDisplay,
isExpanded: self.$isErrorExpanded,
onCopy: { self.onCopyError(errorDisplay.full) })
@@ -147,12 +147,12 @@ struct ProviderDetailView: View {
}
private var detailLabelWidth: CGFloat {
- var infoLabels = ["State", "Source", "Version", "Updated"]
+ var infoLabels = ["State", "Source", "Version", "Updated"].map(localizedUI)
if self.store.status(for: self.provider) != nil {
- infoLabels.append("Status")
+ infoLabels.append(localizedUI("Status"))
}
if !self.model.email.isEmpty {
- infoLabels.append("Account")
+ infoLabels.append(localizedUI("Account"))
}
if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) {
infoLabels.append(planRow.label)
@@ -162,13 +162,13 @@ struct ProviderDetailView: View {
Self.metricTitle(provider: self.provider, metric: metric)
}
if self.model.creditsText != nil {
- metricLabels.append("Credits")
+ metricLabels.append(localizedUI("Credits"))
}
if let providerCost = self.model.providerCost {
metricLabels.append(providerCost.title)
}
if self.model.tokenUsage != nil {
- metricLabels.append("Cost")
+ metricLabels.append(localizedUI("Cost"))
}
let infoWidth = ProviderSettingsMetrics.labelWidth(
@@ -214,7 +214,7 @@ private struct ProviderDetailHeaderView: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
- .help("Refresh")
+ .help(localizedUI("Refresh"))
Toggle("", isOn: self.$isEnabled)
.labelsHidden()
@@ -274,26 +274,26 @@ private struct ProviderDetailInfoGrid: View {
var body: some View {
let status = self.store.status(for: self.provider)
let source = self.store.sourceLabel(for: self.provider)
- let version = self.store.version(for: self.provider) ?? "not detected"
+ let version = localizedUI(self.store.version(for: self.provider) ?? "not detected")
let updated = self.updatedText
let email = self.model.email
- let enabledText = self.isEnabled ? "Enabled" : "Disabled"
+ let enabledText = localizedUI(self.isEnabled ? "Enabled" : "Disabled")
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
- ProviderDetailInfoRow(label: "State", value: enabledText, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Source", value: source, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Version", value: version, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Updated", value: updated, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(label: localizedUI("State"), value: source.isEmpty ? enabledText : enabledText, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(label: localizedUI("Source"), value: localizedUI(source), labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(label: localizedUI("Version"), value: version, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(label: localizedUI("Updated"), value: updated, labelWidth: self.labelWidth)
if let status {
ProviderDetailInfoRow(
- label: "Status",
- value: status.description ?? status.indicator.label,
+ label: localizedUI("Status"),
+ value: localizedUI(status.description ?? status.indicator.label),
labelWidth: self.labelWidth)
}
if !email.isEmpty {
- ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(label: localizedUI("Account"), value: email, labelWidth: self.labelWidth)
}
if let planRow = ProviderDetailView.planRow(
@@ -312,12 +312,12 @@ private struct ProviderDetailInfoGrid: View {
return UsageFormatter.updatedString(from: updated)
}
if self.store.refreshingProviders.contains(self.provider) {
- return "Refreshing"
+ return localizedUI("Refreshing")
}
if self.store.unavailableMessage(for: self.provider) != nil {
- return "Unavailable"
+ return localizedUI("Unavailable")
}
- return "Not fetched yet"
+ return localizedUI("Not fetched yet")
}
}
@@ -356,7 +356,7 @@ struct ProviderMetricsInlineView: View {
horizontalPadding: 0)
{
if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage {
- Text(self.placeholderText)
+ Text(localizedUI(self.placeholderText))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
@@ -377,7 +377,7 @@ struct ProviderMetricsInlineView: View {
if let credits = self.model.creditsText {
ProviderMetricInlineTextRow(
- title: "Credits",
+ title: localizedUI("Credits"),
value: credits,
labelWidth: self.labelWidth)
}
@@ -391,7 +391,7 @@ struct ProviderMetricsInlineView: View {
if let tokenUsage = self.model.tokenUsage {
ProviderMetricInlineTextRow(
- title: "Cost",
+ title: localizedUI("Cost"),
value: tokenUsage.sessionLine,
labelWidth: self.labelWidth)
ProviderMetricInlineTextRow(
@@ -405,9 +405,9 @@ struct ProviderMetricsInlineView: View {
private var placeholderText: String {
if !self.isEnabled {
- return "Disabled — no recent data"
+ return localizedUI("Disabled — no recent data")
}
- return self.model.placeholder ?? "No usage yet"
+ return self.model.placeholder ?? localizedUI("No usage yet")
}
}
@@ -494,7 +494,7 @@ private struct ProviderUsageNotesInlineView: View {
}
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in
- Text(note)
+ Text(localizedUI(note))
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -515,6 +515,7 @@ private struct ProviderMetricInlineTextRow: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(self.title)
+ .help(localizedUI(self.title))
.font(.subheadline.weight(.semibold))
.frame(width: self.labelWidth, alignment: .leading)
@@ -543,11 +544,11 @@ private struct ProviderMetricInlineCostRow: View {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
- accessibilityLabel: "Usage used")
+ accessibilityLabel: localizedUI("Usage used"))
.frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(String(format: "%.0f%% used", self.section.percentUsed))
+ Text(localizedUIFormat("%.0f%% used", self.section.percentUsed))
.font(.footnote)
.foregroundStyle(.secondary)
.monospacedDigit()
diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
index 514c7e3ce..a998e311e 100644
--- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift
+++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
@@ -41,9 +41,9 @@ struct ProviderSettingsToggleRowView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
- Text(self.toggle.title)
+ Text(localizedUI(self.toggle.title))
.font(.subheadline.weight(.semibold))
- Text(self.toggle.subtitle)
+ Text(localizedUI(self.toggle.subtitle))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -56,7 +56,7 @@ struct ProviderSettingsToggleRowView: View {
if self.toggle.binding.wrappedValue {
if let status = self.toggle.statusText?(), !status.isEmpty {
- Text(status)
+ Text(localizedUI(status))
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(4)
@@ -67,7 +67,7 @@ struct ProviderSettingsToggleRowView: View {
if !actions.isEmpty {
HStack(spacing: 10) {
ForEach(actions) { action in
- Button(action.title) {
+ Button(localizedUI(action.title)) {
Task { @MainActor in
await action.perform()
}
@@ -101,13 +101,13 @@ struct ProviderSettingsPickerRowView: View {
let isEnabled = self.picker.isEnabled?() ?? true
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
- Text(self.picker.title)
+ Text(localizedUI(self.picker.title))
.font(.subheadline.weight(.semibold))
.frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading)
Picker("", selection: self.picker.binding) {
ForEach(self.picker.options) { option in
- Text(option.title).tag(option.id)
+ Text(localizedUI(option.title)).tag(option.id)
}
}
.labelsHidden()
@@ -115,7 +115,7 @@ struct ProviderSettingsPickerRowView: View {
.controlSize(.small)
if let trailingText = self.picker.trailingText?(), !trailingText.isEmpty {
- Text(trailingText)
+ Text(localizedUI(trailingText))
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -128,7 +128,7 @@ struct ProviderSettingsPickerRowView: View {
let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle
if !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- Text(subtitle)
+ Text(localizedUI(subtitle))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -157,11 +157,11 @@ struct ProviderSettingsFieldRowView: View {
if hasHeader {
VStack(alignment: .leading, spacing: 4) {
if !trimmedTitle.isEmpty {
- Text(trimmedTitle)
+ Text(localizedUI(trimmedTitle))
.font(.subheadline.weight(.semibold))
}
if !trimmedSubtitle.isEmpty {
- Text(trimmedSubtitle)
+ Text(localizedUI(trimmedSubtitle))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -171,12 +171,12 @@ struct ProviderSettingsFieldRowView: View {
switch self.field.kind {
case .plain:
- TextField(self.field.placeholder ?? "", text: self.field.binding)
+ TextField(localizedUI(self.field.placeholder ?? ""), text: self.field.binding)
.textFieldStyle(.roundedBorder)
.font(.footnote)
.onTapGesture { self.field.onActivate?() }
case .secure:
- SecureField(self.field.placeholder ?? "", text: self.field.binding)
+ SecureField(localizedUI(self.field.placeholder ?? ""), text: self.field.binding)
.textFieldStyle(.roundedBorder)
.font(.footnote)
.onTapGesture { self.field.onActivate?() }
@@ -186,7 +186,7 @@ struct ProviderSettingsFieldRowView: View {
if !actions.isEmpty {
HStack(spacing: 10) {
ForEach(actions) { action in
- Button(action.title) {
+ Button(localizedUI(action.title)) {
Task { @MainActor in
await action.perform()
}
@@ -198,7 +198,7 @@ struct ProviderSettingsFieldRowView: View {
}
if let footer = self.field.footerText, !footer.isEmpty {
- Text(footer)
+ Text(localizedUI(footer))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -215,11 +215,11 @@ struct ProviderSettingsTokenAccountsRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
- Text(self.descriptor.title)
+ Text(localizedUI(self.descriptor.title))
.font(.subheadline.weight(.semibold))
if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- Text(self.descriptor.subtitle)
+ Text(localizedUI(self.descriptor.subtitle))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -227,7 +227,7 @@ struct ProviderSettingsTokenAccountsRowView: View {
let accounts = self.descriptor.accounts()
if accounts.isEmpty {
- Text("No token accounts yet.")
+ Text(localizedUI("No token accounts yet."))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
@@ -244,7 +244,7 @@ struct ProviderSettingsTokenAccountsRowView: View {
.pickerStyle(.menu)
.controlSize(.small)
- Button("Remove selected account") {
+ Button(localizedUI("Remove selected account")) {
let account = accounts[selectedIndex]
self.descriptor.removeAccount(account.id)
}
@@ -253,13 +253,13 @@ struct ProviderSettingsTokenAccountsRowView: View {
}
HStack(spacing: 8) {
- TextField("Label", text: self.$newLabel)
+ TextField(localizedUI("Label"), text: self.$newLabel)
.textFieldStyle(.roundedBorder)
.font(.footnote)
- SecureField(self.descriptor.placeholder, text: self.$newToken)
+ SecureField(localizedUI(self.descriptor.placeholder), text: self.$newToken)
.textFieldStyle(.roundedBorder)
.font(.footnote)
- Button("Add") {
+ Button(localizedUI("Add")) {
let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty, !token.isEmpty else { return }
@@ -274,12 +274,12 @@ struct ProviderSettingsTokenAccountsRowView: View {
}
HStack(spacing: 10) {
- Button("Open token file") {
+ Button(localizedUI("Open token file")) {
self.descriptor.openConfigFile()
}
.buttonStyle(.link)
.controlSize(.small)
- Button("Reload") {
+ Button(localizedUI("Reload")) {
self.descriptor.reloadFromDisk()
}
.buttonStyle(.link)
diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift
index 559c5329e..fa3df14cc 100644
--- a/Sources/CodexBar/PreferencesProviderSidebarView.swift
+++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift
@@ -72,7 +72,7 @@ private struct ProviderSidebarRowView: View {
.contentShape(Rectangle())
.padding(.vertical, 4)
.padding(.horizontal, 2)
- .help("Drag to reorder")
+ .help(localizedUI("Drag to reorder"))
.onDrag {
self.draggingProvider = self.provider
return NSItemProvider(object: self.provider.rawValue as NSString)
@@ -95,7 +95,7 @@ private struct ProviderSidebarRowView: View {
.controlSize(.mini)
}
}
- Text(statusText)
+ Text(localizedUI(statusText))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -119,9 +119,9 @@ private struct ProviderSidebarRowView: View {
if lines.count >= 2 {
let first = lines[0]
let rest = lines.dropFirst().joined(separator: "\n")
- return "Disabled — \(first)\n\(rest)"
+ return localizedUIFormat("Disabled — %@\n%@", String(first), rest)
}
- return "Disabled — \(self.subtitle)"
+ return localizedUIFormat("Disabled — %@", self.subtitle)
}
}
@@ -145,7 +145,7 @@ private struct ProviderSidebarReorderHandle: View {
width: ProviderSettingsMetrics.reorderHandleSize,
height: ProviderSettingsMetrics.reorderHandleSize)
.foregroundStyle(.tertiary)
- .accessibilityLabel("Reorder")
+ .accessibilityLabel(localizedUI("Reorder"))
}
}
diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift
index 408a83d6d..8f5a8591f 100644
--- a/Sources/CodexBar/PreferencesView.swift
+++ b/Sources/CodexBar/PreferencesView.swift
@@ -15,12 +15,18 @@ enum PreferencesTab: String, CaseIterable, Hashable {
var title: String {
switch self {
- case .general: "General"
- case .providers: "Providers"
- case .display: "Display"
- case .advanced: "Advanced"
- case .about: "About"
- case .debug: "Debug"
+ case .general:
+ NSLocalizedString("pref.tab.general", comment: "")
+ case .providers:
+ NSLocalizedString("pref.tab.providers", comment: "")
+ case .display:
+ NSLocalizedString("pref.tab.display", comment: "")
+ case .advanced:
+ NSLocalizedString("pref.tab.advanced", comment: "")
+ case .about:
+ NSLocalizedString("pref.tab.about", comment: "")
+ case .debug:
+ NSLocalizedString("pref.tab.debug", comment: "")
}
}
@@ -67,7 +73,7 @@ struct PreferencesView: View {
var body: some View {
TabView(selection: self.$selection.tab) {
GeneralPane(settings: self.settings, store: self.store)
- .tabItem { Label("General", systemImage: "gearshape") }
+ .tabItem { Label("pref.tab.general", systemImage: "gearshape") }
.tag(PreferencesTab.general)
ProvidersPane(
@@ -75,24 +81,24 @@ struct PreferencesView: View {
store: self.store,
managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator)
- .tabItem { Label("Providers", systemImage: "square.grid.2x2") }
+ .tabItem { Label("pref.tab.providers", systemImage: "square.grid.2x2") }
.tag(PreferencesTab.providers)
DisplayPane(settings: self.settings, store: self.store)
- .tabItem { Label("Display", systemImage: "eye") }
+ .tabItem { Label("pref.tab.display", systemImage: "eye") }
.tag(PreferencesTab.display)
AdvancedPane(settings: self.settings)
- .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
+ .tabItem { Label("pref.tab.advanced", systemImage: "slider.horizontal.3") }
.tag(PreferencesTab.advanced)
AboutPane(updater: self.updater)
- .tabItem { Label("About", systemImage: "info.circle") }
+ .tabItem { Label("pref.tab.about", systemImage: "info.circle") }
.tag(PreferencesTab.about)
if self.settings.debugMenuEnabled {
DebugPane(settings: self.settings, store: self.store)
- .tabItem { Label("Debug", systemImage: "ladybug") }
+ .tabItem { Label("pref.tab.debug", systemImage: "ladybug") }
.tag(PreferencesTab.debug)
}
}
diff --git a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
index 1e0cdeb9f..3ed8791db 100644
--- a/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
@@ -66,7 +66,7 @@ struct AbacusProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift
index eb198478e..71090af7e 100644
--- a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift
@@ -71,7 +71,7 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .alibaba) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
ProviderSettingsPickerDescriptor(
id: "alibaba-coding-plan-region",
diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift
index c1529bd58..f87202492 100644
--- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift
@@ -70,7 +70,7 @@ struct AugmentProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .augment) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
@@ -83,14 +83,14 @@ struct AugmentProviderImplementation: ProviderImplementation {
@MainActor
func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) {
- entries.append(.action("Refresh Session", .refreshAugmentSession))
+ entries.append(.action(localizedUI("Refresh Session"), .refreshAugmentSession))
if let error = context.store.error(for: .augment) {
if error.contains("session has expired") ||
error.contains("No Augment session cookie found")
{
entries.append(.action(
- "Open Augment (Log Out & Back In)",
+ localizedUI("Open Augment (Log Out & Back In)"),
.loginToProvider(url: "https://app.augmentcode.com")))
}
}
diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift
index da82c1734..d3894b28e 100644
--- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift
@@ -196,7 +196,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .claude) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
@@ -216,7 +216,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
@MainActor
func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) {
if context.snapshot?.secondary == nil {
- entries.append(.text("Weekly usage unavailable for this account.", .secondary))
+ entries.append(.text(localizedUI("Weekly usage unavailable for this account."), .secondary))
}
if let cost = context.snapshot?.providerCost,
@@ -225,7 +225,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
{
let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
- entries.append(.text("Extra usage: \(used) / \(limit)", .primary))
+ entries.append(.text(localizedUIFormat("usage.extra_usage.inline", used, limit), .primary))
}
}
@@ -234,7 +234,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
-> (label: String, action: MenuDescriptor.MenuAction)?
{
guard self.shouldOpenTerminalForOAuthError(store: context.store) else { return nil }
- return ("Open Terminal", .openTerminal(command: "claude"))
+ return (localizedUI("Open Terminal"), .openTerminal(command: "claude"))
}
@MainActor
diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
index 9a39c3af1..67a089d5e 100644
--- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
@@ -169,7 +169,7 @@ struct CodexProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
@@ -199,9 +199,13 @@ struct CodexProviderImplementation: ProviderImplementation {
else { return }
if let credits = context.store.credits {
- entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary))
+ entries.append(.text(
+ localizedUIFormat("usage.credits.inline", UsageFormatter.creditsString(from: credits.remaining)),
+ .primary))
if let latest = credits.events.first {
- entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary))
+ entries.append(.text(
+ localizedUIFormat("usage.last_spend.inline", UsageFormatter.creditEventSummary(latest)),
+ .secondary))
}
} else {
let hint = context.store.userFacingLastCreditsError ?? context.metadata.creditsHint
@@ -237,7 +241,7 @@ struct CodexProviderImplementation: ProviderImplementation {
}
entries.append(.submenu(
- "System Account",
+ localizedUI("System Account"),
MenuDescriptor.MenuActionSystemImage.systemAccount.rawValue,
submenuItems))
}
diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift
index 48db614f9..66ef1a46c 100644
--- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift
@@ -71,7 +71,7 @@ struct CursorProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .cursor) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
@@ -94,9 +94,9 @@ struct CursorProviderImplementation: ProviderImplementation {
let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
if cost.limit > 0 {
let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
- entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary))
+ entries.append(.text(localizedUIFormat("usage.on_demand.with_limit", used, limitStr), .primary))
} else {
- entries.append(.text("On-Demand: \(used)", .primary))
+ entries.append(.text(localizedUIFormat("usage.on_demand", used), .primary))
}
}
}
diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift
index c4fd16117..bf62d5b72 100644
--- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift
@@ -66,7 +66,7 @@ struct FactoryProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .factory) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift
index 6b7432edc..0465abffe 100644
--- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift
@@ -91,7 +91,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .minimax) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
ProviderSettingsPickerDescriptor(
id: "minimax-region",
diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
index 1949d2ed0..ce235c50a 100644
--- a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
@@ -71,7 +71,7 @@ struct MistralProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}),
]
}
diff --git a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift
index e6646e6c2..159787a55 100644
--- a/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift
+++ b/Sources/CodexBar/Providers/OpenCode/OpenCodeProviderUI.swift
@@ -7,6 +7,6 @@ enum OpenCodeProviderUI {
guard cookieSource != .manual else { return nil }
guard let entry = CookieHeaderCache.load(provider: provider) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ return localizedUIFormat("provider.cache.cached_at", entry.sourceLabel, when)
}
}
diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift
index 1f8fe5418..15ea16650 100644
--- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift
+++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift
@@ -7,20 +7,11 @@ extension StatusItemController {
func runVertexAILoginFlow() async {
// Show alert with instructions
let alert = NSAlert()
- alert.messageText = "Vertex AI Login"
- alert.informativeText = """
- To use Vertex AI tracking, you need to authenticate with Google Cloud.
-
- 1. Open Terminal
- 2. Run: gcloud auth application-default login
- 3. Follow the browser prompts to sign in
- 4. Set your project: gcloud config set project PROJECT_ID
-
- Would you like to open Terminal now?
- """
+ alert.messageText = NSLocalizedString("vertex_ai.login.title", comment: "")
+ alert.informativeText = NSLocalizedString("vertex_ai.login.instructions", comment: "")
alert.alertStyle = .informational
- alert.addButton(withTitle: "Open Terminal")
- alert.addButton(withTitle: "Cancel")
+ alert.addButton(withTitle: NSLocalizedString("Open Terminal", comment: ""))
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
let response = alert.runModal()
diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings
new file mode 100644
index 000000000..87aad6ef7
--- /dev/null
+++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings
@@ -0,0 +1,353 @@
+/* ===== Preferences Tabs ===== */
+"pref.tab.general" = "General";
+"pref.tab.providers" = "Providers";
+"pref.tab.display" = "Display";
+"pref.tab.advanced" = "Advanced";
+"pref.tab.about" = "About";
+"pref.tab.debug" = "Debug";
+
+/* ===== General Pane ===== */
+"pref.general.section.system" = "System";
+"pref.general.launch_at_login.title" = "Start at Login";
+"pref.general.launch_at_login.subtitle" = "Automatically opens CodexBar when you start your Mac.";
+"pref.general.section.usage" = "Usage";
+"pref.general.cost_summary.title" = "Show cost summary";
+"pref.general.cost_summary.subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu.";
+"pref.general.auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m";
+"pref.general.section.automation" = "Automation";
+"pref.general.refresh_cadence.title" = "Refresh cadence";
+"pref.general.refresh_cadence.subtitle" = "How often CodexBar polls providers in the background.";
+"pref.general.refresh_cadence.manual_note" = "Auto-refresh is off; use the menu's Refresh command.";
+"pref.general.provider_status.title" = "Check provider status";
+"pref.general.provider_status.subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu.";
+"pref.general.session_notifications.title" = "Session quota notifications";
+"pref.general.session_notifications.subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again.";
+"pref.general.section.language" = "Language";
+"pref.general.app_language.title" = "App Language";
+"pref.general.app_language.subtitle" = "Takes effect after restarting CodexBar.";
+"pref.general.quit" = "Quit CodexBar";
+
+/* Cost status lines */
+"pref.general.cost.unsupported" = "%@: unsupported";
+"pref.general.cost.fetching" = "%@: fetching…%@";
+"pref.general.cost.updated" = "%@: %@ · 30d %@";
+"pref.general.cost.last_attempt" = "%@: last attempt %@";
+"pref.general.cost.no_data" = "%@: no data yet";
+
+/* ===== Menu Bar Items ===== */
+"menu.refresh" = "Refresh";
+"menu.overview" = "Overview";
+"menu.settings" = "Settings...";
+"menu.about" = "About CodexBar";
+"menu.quit" = "Quit";
+"menu.dashboard" = "Usage Dashboard";
+"menu.status_page" = "Status Page";
+"menu.update_ready" = "Update ready, restart now?";
+"menu.refresh_session" = "Refresh Session";
+"menu.add_account" = "Add Account...";
+"menu.switch_account" = "Switch Account...";
+"menu.no_usage_configured" = "No usage configured.";
+"menu.no_usage_yet" = "No usage yet";
+"menu.buy_credits" = "Buy Credits...";
+"menu.credits_history" = "Credits history";
+"menu.usage_breakdown" = "Usage breakdown";
+"menu.usage_history_30d" = "Usage history (30 days)";
+"menu.no_providers_overview" = "No providers selected for Overview.";
+"menu.no_overview_data" = "No overview data available.";
+"menu.account_prefix" = "Account: %@";
+"menu.plan_prefix" = "Plan: %@";
+"menu.activity_prefix" = "Activity: %@";
+"menu.quota_display" = "Quota: %@ / %@";
+
+/* ===== Usage / Time ===== */
+"time.just_now" = "just now";
+"time.now" = "now";
+"time.in_prefix" = "in ";
+"time.in_minutes" = "in %dm";
+"time.in_hours" = "in %dh";
+"time.in_hours_minutes" = "in %dh %dm";
+"time.in_days" = "in %dd";
+"time.in_days_hours" = "in %dd %dh";
+"time.tomorrow_at" = "tomorrow, %@";
+"usage.percent.left" = "left";
+"usage.percent.used" = "used";
+"usage.resets" = "Resets %@";
+"usage.updated.just_now" = "Updated just now";
+"usage.updated.relative" = "Updated %@";
+"usage.updated.minutes_ago" = "Updated %dm ago";
+"usage.updated.hours_ago" = "Updated %dh ago";
+"usage.updated.at_time" = "Updated %@";
+"usage.pace.summary" = "Pace: %@";
+"usage.pace.summary.detail" = "Pace: %@ · %@";
+"usage.pace.on_track" = "On pace";
+"usage.pace.in_deficit" = "%d%% in deficit";
+"usage.pace.in_reserve" = "%d%% in reserve";
+"usage.pace.lasts_until_reset" = "Lasts until reset";
+"usage.pace.runs_out_now" = "Runs out now";
+"usage.pace.runs_out_in" = "Runs out in %@";
+"usage.pace.run_out_risk" = "≈ %d%% run-out risk";
+"credits.left" = "%@ left";
+"Disabled — %@" = "Disabled — %@";
+"Disabled — %@\n%@" = "Disabled — %@\n%@";
+"%.0f%% used" = "%.0f%% used";
+"%@ tokens" = "%@ tokens";
+
+/* ===== Common Metric Labels ===== */
+"Overview" = "Overview";
+"Session" = "Session";
+"Weekly" = "Weekly";
+"Designs" = "Designs";
+"Daily Routines" = "Daily Routines";
+"Peak" = "Peak";
+"API key limit" = "API key limit";
+"Automatic" = "Automatic";
+"Primary" = "Primary";
+"Secondary" = "Secondary";
+"Tertiary" = "Tertiary";
+"Extra usage" = "Extra usage";
+"Average" = "Average";
+"Copied" = "Copied";
+"Copy error" = "Copy error";
+"Credits remaining" = "Credits remaining";
+"Extra usage spent" = "Extra usage spent";
+"No data available" = "No data available";
+"Drag to reorder" = "Drag to reorder";
+"Reorder" = "Reorder";
+"Knight Rider" = "Knight Rider";
+"Cylon" = "Cylon";
+"Outside-In" = "Outside-In";
+"Race" = "Race";
+"Pulse" = "Pulse";
+"Unbraid (logo → bars)" = "Unbraid (logo → bars)";
+
+/* ===== Shared Preferences / Detail ===== */
+"Logging" = "Logging";
+"Enable file logging" = "Enable file logging";
+"Write logs to %@ for debugging." = "Write logs to %@ for debugging.";
+"Force animation on next refresh" = "Force animation on next refresh";
+"Temporarily shows the loading animation after the next refresh." = "Temporarily shows the loading animation after the next refresh.";
+"Loading animations" = "Loading animations";
+"Random (default)" = "Random (default)";
+"Probe logs" = "Probe logs";
+"Fetch strategy attempts" = "Fetch strategy attempts";
+"OpenAI cookies" = "OpenAI cookies";
+"Caches" = "Caches";
+"Notifications" = "Notifications";
+"CLI sessions" = "CLI sessions";
+"Keep CLI sessions alive" = "Keep CLI sessions alive";
+"Skip teardown between probes (debug-only)." = "Skip teardown between probes (debug-only).";
+"Error simulation" = "Error simulation";
+"CLI paths" = "CLI paths";
+"Codex binary" = "Codex binary";
+"Claude binary" = "Claude binary";
+"Effective PATH" = "Effective PATH";
+"Login shell PATH (startup capture)" = "Login shell PATH (startup capture)";
+"No fetch attempts yet." = "No fetch attempts yet.";
+"Keyboard shortcut" = "Keyboard shortcut";
+"Open menu" = "Open menu";
+"Trigger the menu bar menu from anywhere." = "Trigger the menu bar menu from anywhere.";
+"Install CLI" = "Install CLI";
+"Show Debug Settings" = "Show Debug Settings";
+"Surprise me" = "Surprise me";
+"Hide personal information" = "Hide personal information";
+"Keychain access" = "Keychain access";
+"Disable Keychain access" = "Disable Keychain access";
+"Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled.";
+"State" = "State";
+"Source" = "Source";
+"Version" = "Version";
+"Updated" = "Updated";
+"Status" = "Status";
+"Account" = "Account";
+"Plan" = "Plan";
+"Balance" = "Balance";
+"Cost" = "Cost";
+"Credits" = "Credits";
+"Usage" = "Usage";
+"Settings" = "Settings";
+"Options" = "Options";
+"Enabled" = "Enabled";
+"Disabled" = "Disabled";
+"Unavailable" = "Unavailable";
+"Not fetched yet" = "Not fetched yet";
+"Refreshing" = "Refreshing";
+"not detected" = "not detected";
+"Disabled — no recent data" = "Disabled — no recent data";
+"Refresh" = "Refresh";
+"Last %@ fetch failed:" = "Last %@ fetch failed:";
+"usage.synthetic.regenerates" = "Regenerates %@";
+"used after next regen" = "used after next regen";
+"after next regen" = "after next regen";
+"Near full" = "Near full";
+"Full in ~1 regen" = "Full in ~1 regen";
+"Full in ~%.0f regens" = "Full in ~%.0f regens";
+"usage.cost.today_tokens" = "Today: %@ · %@ tokens";
+"usage.cost.today" = "Today: %@";
+"usage.cost.last_30_days_tokens" = "Last 30 days: %@ · %@ tokens";
+"usage.cost.last_30_days" = "Last 30 days: %@";
+"Quota usage" = "Quota usage";
+"This month" = "This month";
+"Subscription Utilization" = "Subscription Utilization";
+"usage.extra_usage.inline" = "Extra usage: %@ / %@";
+"claude.peak.off_peak" = "Off-peak";
+"claude.peak.ends_in" = "Peak · ends in %@";
+"claude.peak.next_peak_in" = "Off-peak · peak in %@";
+"error.claude.cli_not_installed" = "Claude CLI is not installed or not on PATH.";
+"error.claude.parse_failed" = "Could not parse Claude usage: %@";
+"error.claude.timed_out" = "Claude usage probe timed out.";
+"provider.cache.cached_at" = "Cached: %@ • %@";
+"Weekly usage unavailable for this account." = "Weekly usage unavailable for this account.";
+"Open Terminal" = "Open Terminal";
+"notification.session.depleted.title" = "%@ session depleted";
+"notification.session.depleted.body" = "0% left. Will notify when it's available again.";
+"notification.session.restored.title" = "%@ session restored";
+"notification.session.restored.body" = "Session quota is available again.";
+"Operational" = "Operational";
+"Partial outage" = "Partial outage";
+"Major outage" = "Major outage";
+"Critical issue" = "Critical issue";
+"Maintenance" = "Maintenance";
+"Status unknown" = "Status unknown";
+"System Account" = "System Account";
+"Inactive while \"Disable Keychain access\" is enabled in Advanced." = "Inactive while \"Disable Keychain access\" is enabled in Advanced.";
+"Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts.";
+"Avoid Keychain prompts" = "Avoid Keychain prompts";
+"Show peak hours indicator" = "Show peak hours indicator";
+"Show whether Claude is in peak usage hours." = "Show whether Claude is in peak usage hours.";
+"Never prompt" = "Never prompt";
+"Only on user action" = "Only on user action";
+"Always allow prompts" = "Always allow prompts";
+"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "Global Keychain access is disabled in Advanced, so this setting is currently inactive.";
+"Controls Claude OAuth Keychain prompts when the standard reader is active. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "Controls Claude OAuth Keychain prompts when the standard reader is active. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed.";
+
+/* ===== Shared Option Labels ===== */
+"System Default" = "System Default";
+"English" = "English";
+"Simplified Chinese" = "Simplified Chinese";
+"Manual" = "Manual";
+"1 min" = "1 min";
+"2 min" = "2 min";
+"5 min" = "5 min";
+"15 min" = "15 min";
+"30 min" = "30 min";
+
+/* ===== Token Accounts ===== */
+"token.no_accounts" = "No token accounts yet.";
+"token.remove_selected" = "Remove selected account";
+"token.label" = "Label";
+"token.add" = "Add";
+"token.open_file" = "Open token file";
+"token.reload" = "Reload";
+
+/* ===== Codex Accounts ===== */
+"codex.accounts.add" = "Add Account";
+"codex.accounts.reauth" = "Re-auth";
+"codex.accounts.reauthenticating" = "Re-authenticating…";
+"codex.accounts.active" = "Active";
+"codex.accounts.section_title" = "Accounts";
+"codex.accounts.choose" = "Choose which Codex account CodexBar should follow.";
+"codex.accounts.none_detected" = "No Codex accounts detected yet.";
+"codex.accounts.default" = "The default Codex account on this Mac.";
+
+/* ===== Login / Auth Errors ===== */
+"error.cannot_add_codex_account" = "Could not add Codex account";
+"error.cannot_switch_system_account" = "Could not switch system account";
+"error.claude_login_start" = "Could not start claude /login";
+"error.codex_login_start" = "Could not start codex login";
+"error.codex_login_running" = "Codex account login already running";
+"error.claude_login_failed" = "Claude login failed";
+"error.cursor_login_failed" = "Cursor login failed";
+"error.cursor.not_logged_in" = "Not logged in to Cursor. Please log in via the CodexBar menu.";
+"error.cursor.no_session" = "No Cursor session found. Please log in to cursor.com in %@. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account).";
+"error.codex_login_failed" = "Codex login failed";
+"error.claude_login_timeout" = "Claude login timed out";
+"error.codex_login_timeout" = "Codex login timed out";
+"error.claude_cli_not_found" = "Claude CLI not found";
+"error.codex_cli_not_found" = "Codex CLI not found";
+"error.gemini_cli_not_found" = "Gemini CLI not found";
+"error.gemini_terminal" = "Could not open Terminal for Gemini";
+"error.managed_codex_unavailable" = "Managed Codex accounts unavailable";
+"error.github_device_flow" = "GitHub Device Flow authentication required";
+"error.failed_open_terminal" = "Failed to open Terminal";
+"error.api_key_limit" = "API key limit";
+"error.api_key_limit_unavailable" = "API key limit unavailable right now";
+
+/* ===== Date / Time ===== */
+"date.just_now" = "just now";
+
+/* ===== Chart Views ===== */
+"chart.no_usage_breakdown" = "No usage breakdown data.";
+"chart.no_credits_history" = "No credits history data.";
+"chart.no_cost_history" = "No cost history data.";
+"chart.hover_for_details" = "Hover a bar for details";
+
+/* ===== ZAI / MCP Submenu ===== */
+"zai.mcp_details" = "MCP details";
+"zai.window" = "Window: %@";
+"zai.resets" = "Resets: %@";
+
+/* ===== Additional Localization Sweep ===== */
+"error.claude.not_installed" = "Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview.";
+"error.claude.delegated_refresh_disabled" = "Delegated refresh is disabled by 'never' keychain policy.";
+"error.claude.oauth_background_repair_suppressed" = "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.";
+"error.claude.folder_trust_prompt.with_folder" = "Claude CLI is waiting for a folder trust prompt (%@). CodexBar tries to auto-accept this, but if it keeps appearing run: `cd \"%@\" && claude` and choose \"Yes, proceed\", then retry.";
+"error.claude.folder_trust_prompt" = "Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps appearing open `claude` once, choose \"Yes, proceed\", then retry.";
+"error.claude.token_expired" = "Claude CLI token expired. Run `claude login` to refresh.";
+"error.claude.authentication_error" = "Claude CLI authentication error. Run `claude login`.";
+"error.claude.rate_limited" = "Claude CLI usage endpoint is rate limited right now. Please try again later.";
+"error.claude.could_not_load_usage" = "Claude CLI could not load usage data. Open the CLI and retry `/usage`.";
+"error.claude.error_with_login_hint" = "%@. Run `claude login` to refresh.";
+"error.claude.generic_error" = "Claude CLI error: %@";
+"error.claude.delegated_refresh_did_not_recover" = "Claude OAuth token expired and delegated Claude CLI refresh did not recover. Run `claude login`, then retry.";
+"error.claude.delegated_refresh_unavailable" = "Claude OAuth token expired; delegated refresh is unavailable (outcome=%@).";
+"error.claude.delegated_refresh_cooling_down" = "Claude OAuth token expired and delegated refresh is cooling down. Please retry shortly, or run `claude login`.";
+"error.claude.delegated_refresh_cli_unavailable" = "Claude OAuth token expired and Claude CLI is not available for delegated refresh. Install/configure `claude`, or run `claude login`.";
+"error.claude.delegated_refresh_still_unavailable" = "Claude OAuth token is still unavailable after delegated Claude CLI refresh. Run `claude login`, then retry.";
+"error.claude.delegated_refresh_failed" = "Claude OAuth token expired and delegated Claude CLI refresh failed: %@. Run `claude login`, then retry.";
+"history.empty_state.for_title" = "No %@ utilization data yet.";
+"history.empty_state" = "No utilization data yet.";
+"history.detail_line.unobserved" = "%@: -";
+"history.detail_line.used" = "%@: %@%% used";
+"usage.on_demand" = "On-Demand: %@";
+"usage.on_demand.with_limit" = "On-Demand: %@ / %@";
+"usage.credits.inline" = "Credits: %@";
+"usage.credits.compact" = "Credits: %@/%@";
+"usage.last_spend.inline" = "Last spend: %@";
+"Opus" = "Opus";
+"Cancel" = "Cancel";
+"Vertex AI Login" = "Vertex AI Login";
+"vertex_ai.login.title" = "Vertex AI Login";
+"vertex_ai.login.instructions" = "To use Vertex AI tracking, you need to authenticate with Google Cloud.\n\n1. Open Terminal\n2. Run: gcloud auth application-default login\n3. Follow the browser prompts to sign in\n4. Set your project: gcloud config set project PROJECT_ID\n\nWould you like to open Terminal now?";
+"Open Augment (Log Out & Back In)" = "Open Augment (Log Out & Back In)";
+"Historical tracking" = "Historical tracking";
+"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores local Codex usage history (8 weeks) to personalize Pace predictions.";
+"OpenAI web extras" = "OpenAI web extras";
+"Optional. Turn this on to show code review, usage breakdown, and credits history via chatgpt.com." = "Optional. Turn this on to show code review, usage breakdown, and credits history via chatgpt.com.";
+"Battery Saver" = "Battery Saver";
+"Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually." = "Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually.";
+"Usage source" = "Usage source";
+"Keychain prompt policy" = "Keychain prompt policy";
+"Claude cookies" = "Claude cookies";
+"Claude cookies are disabled." = "Claude cookies are disabled.";
+"Automatic imports browser cookies or stored sessions." = "Automatic imports browser cookies or stored sessions.";
+"Paste a Cookie header from a cursor.com request." = "Paste a Cookie header from a cursor.com request.";
+"Automatic imports browser cookies and WorkOS tokens." = "Automatic imports browser cookies and WorkOS tokens.";
+"Paste a Cookie header from app.factory.ai." = "Paste a Cookie header from app.factory.ai.";
+"Automatic imports browser cookies from admin.mistral.ai." = "Automatic imports browser cookies from admin.mistral.ai.";
+"Paste a Cookie header captured from the billing page." = "Paste a Cookie header captured from the billing page.";
+"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie.";
+"Open Mistral Admin" = "Open Mistral Admin";
+"Automatic imports browser cookies from Model Studio/Bailian." = "Automatic imports browser cookies from Model Studio/Bailian.";
+"Paste a Cookie header from modelstudio.console.alibabacloud.com." = "Paste a Cookie header from modelstudio.console.alibabacloud.com.";
+"Gateway region" = "Gateway region";
+"API key" = "API key";
+"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio.";
+"Open Coding Plan" = "Open Coding Plan";
+"Automatic imports browser cookies." = "Automatic imports browser cookies.";
+"Paste a Cookie header or cURL capture from the Abacus AI dashboard." = "Paste a Cookie header or cURL capture from the Abacus AI dashboard.";
+"Open Dashboard" = "Open Dashboard";
+"Paste a Cookie header or cURL capture from the Augment dashboard." = "Paste a Cookie header or cURL capture from the Augment dashboard.";
+"Open Droid in Browser..." = "Open Droid in Browser...";
+"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stored in ~/.codexbar/config.json. Paste your MiniMax API key.";
+"Choose the MiniMax host (global .io or China mainland .com)." = "Choose the MiniMax host (global .io or China mainland .com).";
+"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access).";
diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
new file mode 100644
index 000000000..f241f906b
--- /dev/null
+++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
@@ -0,0 +1,516 @@
+/* ===== Preferences Tabs ===== */
+"pref.tab.general" = "通用";
+"pref.tab.providers" = "服务商";
+"pref.tab.display" = "显示";
+"pref.tab.advanced" = "高级";
+"pref.tab.about" = "关于";
+"pref.tab.debug" = "调试";
+
+/* ===== General Pane ===== */
+"pref.general.section.system" = "系统";
+"pref.general.launch_at_login.title" = "登录时启动";
+"pref.general.launch_at_login.subtitle" = "启动 Mac 时自动打开 CodexBar。";
+"pref.general.section.usage" = "用量";
+"pref.general.cost_summary.title" = "显示费用摘要";
+"pref.general.cost_summary.subtitle" = "读取本地用量日志,在菜单中显示今日及过去 30 天的费用。";
+"pref.general.auto_refresh_info" = "自动刷新:每小时 · 超时:10 分钟";
+"pref.general.section.automation" = "自动化";
+"pref.general.refresh_cadence.title" = "刷新频率";
+"pref.general.refresh_cadence.subtitle" = "CodexBar 在后台轮询服务商的频率。";
+"pref.general.refresh_cadence.manual_note" = "自动刷新已关闭,请使用菜单中的“刷新”命令。";
+"pref.general.provider_status.title" = "检查服务商状态";
+"pref.general.provider_status.subtitle" = "轮询 OpenAI/Claude 状态页面及 Google Workspace(用于 Gemini/Antigravity),在图标和菜单中显示故障事件。";
+"pref.general.session_notifications.title" = "会话配额通知";
+"pref.general.session_notifications.subtitle" = "当 5 小时会话配额降至 0% 以及恢复可用时发出通知。";
+"pref.general.section.language" = "语言";
+"pref.general.app_language.title" = "应用语言";
+"pref.general.app_language.subtitle" = "重启 CodexBar 后生效。";
+"pref.general.quit" = "退出 CodexBar";
+
+/* Cost status lines */
+"pref.general.cost.unsupported" = "%@: 不支持";
+"pref.general.cost.fetching" = "%@: 获取中…%@";
+"pref.general.cost.updated" = "%@: %@ · 近 30 天 %@";
+"pref.general.cost.last_attempt" = "%@: 上次尝试 %@";
+"pref.general.cost.no_data" = "%@: 暂无数据";
+
+/* ===== Display Pane ===== */
+"Menu bar" = "菜单栏";
+"Merge Icons" = "合并图标";
+"Use a single menu bar icon with a provider switcher." = "使用单一菜单栏图标搭配服务商切换器。";
+"Switcher shows icons" = "切换器显示图标";
+"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切换器中显示服务商图标(否则显示每周进度条)。";
+"Show most-used provider" = "显示使用最多的服务商";
+"Menu bar auto-shows the provider closest to its rate limit." = "菜单栏自动显示最接近限额的服务商。";
+"Menu bar shows percent" = "菜单栏显示百分比";
+"Replace critter bars with provider branding icons and a percentage." = "将动态条替换为服务商品牌图标和百分比。";
+"Display mode" = "显示模式";
+"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "选择菜单栏显示内容(“进度”模式显示实际用量与预期用量的对比)。";
+"Menu content" = "菜单内容";
+"Show usage as used" = "显示已用量";
+"Progress bars fill as you consume quota (instead of showing remaining)." = "进度条随配额消耗填充(而非显示剩余量)。";
+"Show reset time as clock" = "以时钟显示重置时间";
+"Display reset times as absolute clock values instead of countdowns." = "以绝对时间显示重置时间,而非倒计时。";
+"Show credits + extra usage" = "显示额度和额外用量";
+"Show Codex Credits and Claude Extra usage sections in the menu." = "在菜单中显示 Codex 额度和 Claude 额外用量部分。";
+"Show all token accounts" = "显示所有令牌账户";
+"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在菜单中堆叠显示令牌账户(否则显示账户切换栏)。";
+"Overview tab providers" = "概览标签页服务商";
+"Configure…" = "配置…";
+"Enable Merge Icons to configure Overview tab providers." = "启用“合并图标”以配置概览标签页的服务商。";
+"No enabled providers available for Overview." = "没有可用于概览的已启用服务商。";
+"Choose up to %lld providers" = "最多选择 %lld 个服务商";
+"Overview rows always follow provider order." = "概览行始终遵循服务商顺序。";
+"No providers selected" = "未选择服务商";
+
+/* ===== Advanced Pane ===== */
+"Keyboard shortcut" = "键盘快捷键";
+"Open menu" = "打开菜单";
+"Trigger the menu bar menu from anywhere." = "从任意位置触发菜单栏菜单。";
+"Install CLI" = "安装 CLI";
+"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "将 CodexBarCLI 符号链接到 /usr/local/bin 和 /opt/homebrew/bin,命名为 codexbar。";
+"CodexBarCLI not found in app bundle." = "应用包中未找到 CodexBarCLI。";
+"No write access: %@" = "无写入权限:%@";
+"Exists: %@" = "已存在:%@";
+"Installed: %@" = "已安装:%@";
+"Failed: %@" = "失败:%@";
+"No writable bin dirs found." = "未找到可写入的 bin 目录。";
+"Show Debug Settings" = "显示调试设置";
+"Expose troubleshooting tools in the Debug tab." = "在调试标签页中显示故障排除工具。";
+"Surprise me" = "给我惊喜";
+"Check if you like your agents having some fun up there." = "开启后,你的 Agent 会在菜单栏来点花样。";
+"Weekly limit confetti" = "每周限额彩纸庆典";
+"Play full-screen confetti when weekly usage resets." = "每周用量重置时播放全屏彩纸动画。";
+"Hide personal information" = "隐藏个人信息";
+"Obscure email addresses in the menu bar and menu UI." = "在菜单栏和菜单界面中隐藏电子邮件地址。";
+"Keychain access" = "钥匙串访问";
+"Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers." = "禁用所有钥匙串读写。浏览器 Cookie 导入不可用;请在“服务商”中手动粘贴 Cookie 头信息。";
+"Disable Keychain access" = "禁用钥匙串访问";
+"Prevents any Keychain access while enabled." = "启用后阻止任何钥匙串访问。";
+
+/* ===== About Pane ===== */
+"Version %@" = "版本 %@";
+"Built %@" = "构建于 %@";
+"May your tokens never run out—keep agent limits in view." = "愿你的 token 永不耗尽——时刻关注 Agent 限额。";
+"Check for updates automatically" = "自动检查更新";
+"Update Channel" = "更新频道";
+"Check for Updates…" = "检查更新…";
+"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 许可证。";
+"Website" = "网站";
+"Email" = "邮件";
+"Updates unavailable in this build." = "此版本不支持更新。";
+
+/* ===== Debug Pane ===== */
+"Logging" = "日志记录";
+"Enable file logging" = "启用文件日志";
+"Write logs to %@ for debugging." = "将日志写入 %@ 以供调试。";
+"Verbosity" = "详细级别";
+"Controls how much detail is logged." = "控制日志的详细程度。";
+"Open log file" = "打开日志文件";
+"Force animation on next refresh" = "下次刷新时强制播放动画";
+"Temporarily shows the loading animation after the next refresh." = "下次刷新后临时显示加载动画。";
+"Loading animations" = "加载动画";
+"Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior." = "选择一个动画模式并在菜单栏中回放。“随机”保持现有行为。";
+"Random (default)" = "随机(默认)";
+"Replay selected animation" = "回放选中动画";
+"Blink now" = "立即闪烁";
+"Probe logs" = "探针日志";
+"Fetch the latest probe output for debugging; Copy keeps the full text." = "获取最新探针输出以供调试;“复制”保留完整文本。";
+"Fetch log" = "获取日志";
+"Copy" = "复制";
+"Save to file" = "保存到文件";
+"Load parse dump" = "加载解析转储";
+"Re-run provider autodetect" = "重新运行服务商自动检测";
+"Loading…" = "加载中…";
+"No log yet. Fetch to load." = "暂无日志,点击获取。";
+"Fetch strategy attempts" = "获取策略尝试";
+"Last fetch pipeline decisions and errors for a provider." = "服务商的最后一次获取流程决策和错误。";
+"No fetch attempts yet." = "暂无获取尝试。";
+"OpenAI cookies" = "OpenAI Cookies";
+"Cookie import + WebKit scrape logs from the last OpenAI cookies attempt." = "来自最后一次 OpenAI Cookies 尝试的 Cookie 导入和 WebKit 抓取日志。";
+"No log yet. Update OpenAI cookies in Providers → Codex to run an import." = "暂无日志。请在“服务商 → Codex”中更新 OpenAI Cookies 以运行导入。";
+"Caches" = "缓存";
+"Clear cached cost scan results." = "清除缓存的费用扫描结果。";
+"Clear cost cache" = "清除费用缓存";
+"Cleared." = "已清除。";
+"Notifications" = "通知";
+"Trigger test notifications for the 5-hour session window (depleted/restored)." = "为 5 小时会话窗口(耗尽/恢复)触发测试通知。";
+"Post depleted" = "发送耗尽通知";
+"Post restored" = "发送恢复通知";
+"CLI sessions" = "CLI 会话";
+"Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured." = "探针后保持 Codex/Claude CLI 会话活跃。默认在数据捕获后退出。";
+"Keep CLI sessions alive" = "保持 CLI 会话活跃";
+"Skip teardown between probes (debug-only)." = "在探针间跳过清理(仅调试)。";
+"Reset CLI sessions" = "重置 CLI 会话";
+"Error simulation" = "错误模拟";
+"Inject a fake error message into the menu card for layout testing." = "向菜单卡片注入虚假错误消息以测试布局。";
+"Simulated error text" = "模拟错误文本";
+"Set menu error" = "设置菜单错误";
+"Clear menu error" = "清除菜单错误";
+"Set cost error" = "设置费用错误";
+"Clear cost error" = "清除费用错误";
+"CLI paths" = "CLI 路径";
+"Resolved Codex binary and PATH layers; startup login PATH capture (short timeout)." = "已解析的 Codex 二进制文件和 PATH 层级;启动时的登录 PATH 捕获(短超时)。";
+"Codex binary" = "Codex 二进制文件";
+"Claude binary" = "Claude 二进制文件";
+"Effective PATH" = "有效 PATH";
+"Unavailable" = "不可用";
+"Login shell PATH (startup capture)" = "登录 Shell PATH(启动捕获)";
+"Not found" = "未找到";
+
+/* ===== Menu Bar Items ===== */
+"Refresh" = "刷新";
+"menu.overview" = "概览";
+"Settings..." = "设置…";
+"About CodexBar" = "关于 CodexBar";
+"Quit" = "退出";
+"Usage Dashboard" = "用量面板";
+"Status Page" = "状态页面";
+"Update ready, restart now?" = "更新就绪,立即重启?";
+"Refresh Session" = "刷新会话";
+"Add Account..." = "添加账户…";
+"Switch Account..." = "切换账户…";
+"No usage configured." = "未配置用量。";
+"No usage yet" = "暂无用量";
+"Buy Credits..." = "购买额度…";
+"Credits history" = "额度历史";
+"Usage breakdown" = "用量明细";
+"Usage history (30 days)" = "用量历史(30 天)";
+"No overview data available." = "暂无概览数据。";
+"No providers selected for Overview." = "概览未选择服务商。";
+"Window: %@" = "窗口:%@";
+"Resets: %@" = "重置:%@";
+
+/* ===== Usage / Time ===== */
+"time.just_now" = "刚刚";
+"time.now" = "现在";
+"time.in_prefix" = "in ";
+"time.in_minutes" = "%d 分钟后";
+"time.in_hours" = "%d 小时后";
+"time.in_hours_minutes" = "%d 小时 %d 分钟后";
+"time.in_days" = "%d 天后";
+"time.in_days_hours" = "%d 天 %d 小时后";
+"time.tomorrow_at" = "明天 %@";
+"usage.percent.left" = "剩余";
+"usage.percent.used" = "已用";
+"usage.resets" = "%@重置";
+"usage.updated.just_now" = "刚刚更新";
+"usage.updated.relative" = "%@更新";
+"usage.updated.minutes_ago" = "%d 分钟前更新";
+"usage.updated.hours_ago" = "%d 小时前更新";
+"usage.updated.at_time" = "%@更新";
+"usage.pace.summary" = "用量进度:%@";
+"usage.pace.summary.detail" = "进度详情:%@ · %@";
+"usage.pace.on_track" = "进度正常";
+"usage.pace.in_deficit" = "超前 %d%%";
+"usage.pace.in_reserve" = "余量 %d%%";
+"usage.pace.lasts_until_reset" = "可撑到重置";
+"usage.pace.runs_out_now" = "现在耗尽";
+"usage.pace.runs_out_in" = "预计%@后耗尽";
+"usage.pace.run_out_risk" = "约 %d%% 耗尽风险";
+"credits.left" = "剩余 %@";
+"Disabled — %@" = "已禁用 — %@";
+"Disabled — %@\n%@" = "已禁用 — %@\n%@";
+"%.0f%% used" = "已用 %.0f%%";
+"%@ tokens" = "%@ tokens";
+
+/* ===== Common Metric Labels ===== */
+"Overview" = "概览";
+"Session" = "会话";
+"Weekly" = "每周";
+"Designs" = "设计";
+"Daily Routines" = "日常例程";
+"Peak" = "高峰";
+"API key limit" = "API 密钥限额";
+"Automatic" = "自动";
+"Primary" = "主窗口";
+"Secondary" = "次窗口";
+"Tertiary" = "第三窗口";
+"Extra usage" = "额外用量";
+"Average" = "平均";
+"Copied" = "已复制";
+"Copy error" = "复制错误";
+"Credits remaining" = "剩余额度";
+"Extra usage spent" = "额外用量已花费";
+"No data available" = "没有可用数据";
+"Drag to reorder" = "拖拽以重新排序";
+"Reorder" = "重新排序";
+"Knight Rider" = "骑士骑手";
+"Cylon" = "赛昂";
+"Outside-In" = "外向内";
+"Race" = "竞速";
+"Pulse" = "脉冲";
+"Unbraid (logo → bars)" = "解编织(logo → 条形)";
+
+/* ===== Shared Preferences / Detail ===== */
+"Logging" = "日志";
+"Enable file logging" = "启用文件日志";
+"Write logs to %@ for debugging." = "将日志写入 %@ 以供调试。";
+"Force animation on next refresh" = "下次刷新时强制动画";
+"Temporarily shows the loading animation after the next refresh." = "下次刷新后临时显示加载动画。";
+"Loading animations" = "加载动画";
+"Random (default)" = "随机(默认)";
+"Probe logs" = "探针日志";
+"Fetch strategy attempts" = "抓取策略尝试";
+"OpenAI cookies" = "OpenAI Cookies";
+"Caches" = "缓存";
+"Notifications" = "通知";
+"CLI sessions" = "CLI 会话";
+"Keep CLI sessions alive" = "保持 CLI 会话活跃";
+"Skip teardown between probes (debug-only)." = "在探针之间跳过清理(仅调试)。";
+"Error simulation" = "错误模拟";
+"CLI paths" = "CLI 路径";
+"Codex binary" = "Codex 二进制";
+"Claude binary" = "Claude 二进制";
+"Effective PATH" = "有效 PATH";
+"Login shell PATH (startup capture)" = "登录 Shell PATH(启动捕获)";
+"No fetch attempts yet." = "还没有抓取尝试。";
+"Keyboard shortcut" = "键盘快捷键";
+"Open menu" = "打开菜单";
+"Trigger the menu bar menu from anywhere." = "从任意位置触发菜单栏菜单。";
+"Install CLI" = "安装 CLI";
+"Show Debug Settings" = "显示调试设置";
+"Surprise me" = "给我惊喜";
+"Hide personal information" = "隐藏个人信息";
+"Keychain access" = "钥匙串访问";
+"Disable Keychain access" = "禁用钥匙串访问";
+"Prevents any Keychain access while enabled." = "启用后阻止任何钥匙串访问。";
+"State" = "状态";
+"Source" = "来源";
+"Version" = "版本";
+"Updated" = "更新时间";
+"Status" = "服务状态";
+"Account" = "账户";
+"Plan" = "套餐";
+"Balance" = "余额";
+"Cost" = "费用";
+"Credits" = "额度";
+"Usage" = "用量";
+"Settings" = "设置";
+"Options" = "选项";
+"Enabled" = "已启用";
+"Disabled" = "已禁用";
+"Unavailable" = "不可用";
+"Not fetched yet" = "尚未获取";
+"Refreshing" = "刷新中";
+"not detected" = "未检测到";
+"Disabled — no recent data" = "已禁用 — 无近期数据";
+"Refresh" = "刷新";
+"Last %@ fetch failed:" = "%@ 最近一次抓取失败:";
+"usage.synthetic.regenerates" = "%@后恢复";
+"used after next regen" = "下次恢复后已用";
+"after next regen" = "下次恢复后";
+"Near full" = "接近满额";
+"Full in ~1 regen" = "约 1 次恢复后满额";
+"Full in ~%.0f regens" = "约 %.0f 次恢复后满额";
+"usage.cost.today_tokens" = "今天:%@ · %@ tokens";
+"usage.cost.today" = "今天:%@";
+"usage.cost.last_30_days_tokens" = "近 30 天:%@ · %@ tokens";
+"usage.cost.last_30_days" = "近 30 天:%@";
+"Quota usage" = "配额用量";
+"This month" = "本月";
+"Subscription Utilization" = "订阅利用率";
+"usage.extra_usage.inline" = "额外用量:%@ / %@";
+"claude.peak.off_peak" = "非高峰时段";
+"claude.peak.ends_in" = "高峰时段 · %@后结束";
+"claude.peak.next_peak_in" = "非高峰时段 · %@后进入高峰";
+"error.claude.cli_not_installed" = "未安装 Claude CLI,或它不在 PATH 中。";
+"error.claude.parse_failed" = "无法解析 Claude 用量:%@";
+"error.claude.timed_out" = "Claude 用量探测超时。";
+"provider.cache.cached_at" = "已缓存:%@ · %@";
+"Weekly usage unavailable for this account." = "此账户暂不提供每周用量。";
+"Open Terminal" = "打开终端";
+"notification.session.depleted.title" = "%@ 会话已耗尽";
+"notification.session.depleted.body" = "剩余 0%。恢复可用后会再次通知。";
+"notification.session.restored.title" = "%@ 会话已恢复";
+"notification.session.restored.body" = "会话配额已恢复可用。";
+"Operational" = "运行正常";
+"Partial outage" = "部分故障";
+"Major outage" = "重大故障";
+"Critical issue" = "严重故障";
+"Maintenance" = "维护中";
+"Status unknown" = "状态未知";
+"System Account" = "系统账户";
+"Inactive while \"Disable Keychain access\" is enabled in Advanced." = "当“高级”中的“禁用钥匙串访问”开启时,此项不生效。";
+"Use /usr/bin/security to read Claude credentials and avoid CodexBar keychain prompts." = "使用 /usr/bin/security 读取 Claude 凭据,避免 CodexBar 弹出钥匙串提示。";
+"Avoid Keychain prompts" = "避免钥匙串提示";
+"Show peak hours indicator" = "显示高峰时段指示";
+"Show whether Claude is in peak usage hours." = "显示 Claude 当前是否处于高峰使用时段。";
+"Never prompt" = "从不提示";
+"Only on user action" = "仅在用户操作时提示";
+"Always allow prompts" = "始终允许提示";
+"Global Keychain access is disabled in Advanced, so this setting is currently inactive." = "高级设置中已禁用全局钥匙串访问,因此此设置当前不生效。";
+"Controls Claude OAuth Keychain prompts when the standard reader is active. Choosing \"Never prompt\" can make OAuth unavailable; use Web/CLI when needed." = "控制标准读取器启用时 Claude OAuth 的钥匙串提示。选择“从不提示”可能导致 OAuth 不可用;需要时请改用 Web/CLI。";
+
+/* ===== Provider Detail View ===== */
+"State" = "状态";
+"Source" = "来源";
+"Version" = "版本";
+"Updated" = "更新时间";
+"Status" = "服务状态";
+"Account" = "账户";
+"Plan" = "套餐";
+"Balance" = "余额";
+"Cost" = "费用";
+"Credits" = "额度";
+"Enabled" = "已启用";
+"Disabled" = "已禁用";
+"Not fetched yet" = "尚未获取";
+"Refreshing" = "刷新中";
+"not detected" = "未检测到";
+"Usage" = "用量";
+"Settings" = "设置";
+"Options" = "选项";
+"Last %@ fetch failed:" = "%@ 最后一次获取失败:";
+"Disabled — no recent data" = "已禁用 — 无近期数据";
+"No usage yet" = "暂无用量";
+
+/* ===== Provider Settings ===== */
+"Select a provider" = "选择服务商";
+"Cookie source" = "Cookie 来源";
+"Cookie header" = "Cookie 头信息";
+"Automatic" = "自动";
+"Manual" = "手动";
+"Manual cookie header" = "手动 Cookie 头信息";
+"Auto falls back to the next source if the preferred one fails." = "首选来源失败时自动回退至下一来源。";
+"Auto uses API first, then falls back to CLI on auth failures." = "优先使用 API,认证失败时回退至 CLI。";
+"Paste a Cookie header from a claude.ai request." = "粘贴来自 claude.ai 请求的 Cookie 头信息。";
+"Paste a Cookie header from a chatgpt.com request." = "粘贴来自 chatgpt.com 请求的 Cookie 头信息。";
+"Paste a Cookie header or cURL capture from the Coding Plan page." = "粘贴来自编程计划页面的 Cookie 头信息或 cURL 捕获内容。";
+"Automatic imports browser cookies for the web API." = "自动导入浏览器 Cookies 用于 Web API。";
+"Automatic imports browser cookies for dashboard extras." = "自动导入浏览器 Cookies 用于仪表盘附加功能。";
+"Automatic imports browser cookies and local storage tokens." = "自动导入浏览器 Cookies 和本地存储令牌。";
+"Sign in with GitHub" = "使用 GitHub 登录";
+"Sign in again" = "重新登录";
+"Sign in via button below" = "通过下方按钮登录";
+"API region" = "API 区域";
+"Menu bar metric" = "菜单栏指标";
+"Choose which window drives the menu bar percent." = "选择哪个窗口驱动菜单栏百分比。";
+
+/* ===== Token Accounts ===== */
+"No token accounts yet." = "暂无令牌账户。";
+"Remove selected account" = "移除选中账户";
+
+/* ===== Shared Option Labels ===== */
+"System Default" = "跟随系统";
+"English" = "English";
+"Simplified Chinese" = "简体中文";
+"Manual" = "手动";
+"1 min" = "1 分钟";
+"2 min" = "2 分钟";
+"5 min" = "5 分钟";
+"15 min" = "15 分钟";
+"30 min" = "30 分钟";
+"Label" = "标签";
+"Add" = "添加";
+"Open token file" = "打开令牌文件";
+"Reload" = "重新加载";
+
+/* ===== Codex Accounts ===== */
+"Add Account" = "添加账户";
+"Re-auth" = "重新认证";
+"Re-authenticating…" = "重新认证中…";
+"Active" = "活跃";
+"Accounts" = "账户";
+"Choose which Codex account CodexBar should follow." = "选择 CodexBar 应跟踪的 Codex 账户。";
+"No Codex accounts detected yet." = "尚未检测到 Codex 账户。";
+"The default Codex account on this Mac." = "此 Mac 上的默认 Codex 账户。";
+
+/* ===== Login / Auth Errors ===== */
+"Could not add Codex account" = "无法添加 Codex 账户";
+"Could not switch system account" = "无法切换系统账户";
+"Could not start claude /login" = "无法启动 claude /login";
+"Could not start codex login" = "无法启动 codex 登录";
+"Codex account login already running" = "Codex 账户登录已在运行";
+"Claude login failed" = "Claude 登录失败";
+"Cursor login failed" = "Cursor 登录失败";
+"error.cursor.not_logged_in" = "未登录 Cursor。请通过 CodexBar 菜单登录。";
+"error.cursor.no_session" = "未找到 Cursor 会话。请先在 %@ 的 cursor.com 中登录。如果你使用 Safari,请在“系统设置 > 隐私与安全性”中为 CodexBar 授予“完全磁盘访问权限”。你也可以直接从 CodexBar 菜单登录 Cursor(添加/切换账户)。";
+"Codex login failed" = "Codex 登录失败";
+"Claude login timed out" = "Claude 登录超时";
+"Codex login timed out" = "Codex 登录超时";
+"Claude CLI not found" = "未找到 Claude CLI";
+"Codex CLI not found" = "未找到 Codex CLI";
+"Gemini CLI not found" = "未找到 Gemini CLI";
+"Could not open Terminal for Gemini" = "无法为 Gemini 打开终端";
+"Managed Codex accounts unavailable" = "托管 Codex 账户不可用";
+"GitHub Device Flow authentication required" = "需要 GitHub Device Flow 认证";
+"Failed to open Terminal" = "无法打开终端";
+"API key limit" = "API 密钥限额";
+"API key limit unavailable right now" = "API 密钥限额暂时不可用";
+"MCP details" = "MCP 详情";
+
+/* ===== Date / Time ===== */
+"just now" = "刚刚";
+
+/* ===== Chart Views ===== */
+"No usage breakdown data." = "暂无用量明细数据。";
+"No credits history data." = "暂无额度历史数据。";
+"No cost history data." = "暂无费用历史数据。";
+"Hover a bar for details" = "悬停在柱状图上查看详情";
+
+/* ===== Additional Localization Sweep ===== */
+"error.claude.not_installed" = "未安装 Claude CLI。请从 https://code.claude.com/docs/en/overview 安装。";
+"error.claude.delegated_refresh_disabled" = "当前钥匙串策略设为“从不提示”,已禁用委托刷新。";
+"error.claude.oauth_background_repair_suppressed" = "Claude OAuth 令牌已过期,但当前钥匙串提示策略设为“仅在用户操作时提示”,后台修复已被抑制。请打开 CodexBar 菜单或点击刷新后重试。";
+"error.claude.folder_trust_prompt.with_folder" = "Claude CLI 正在等待文件夹信任确认(%@)。CodexBar 会尝试自动接受;如果它反复出现,请运行:`cd \"%@\" && claude`,选择“是,继续”,然后再试。";
+"error.claude.folder_trust_prompt" = "Claude CLI 正在等待文件夹信任确认。CodexBar 会尝试自动接受;如果它反复出现,请先手动打开一次 `claude`,选择“是,继续”,然后再试。";
+"error.claude.token_expired" = "Claude CLI 令牌已过期。请运行 `claude login` 刷新。";
+"error.claude.authentication_error" = "Claude CLI 认证出错。请运行 `claude login`。";
+"error.claude.rate_limited" = "Claude CLI 用量接口当前被限流,请稍后再试。";
+"error.claude.could_not_load_usage" = "Claude CLI 无法加载用量数据。请打开 CLI 后重试 `/usage`。";
+"error.claude.error_with_login_hint" = "%@。请运行 `claude login` 刷新。";
+"error.claude.generic_error" = "Claude CLI 错误:%@";
+"error.claude.delegated_refresh_did_not_recover" = "Claude OAuth 令牌已过期,且委托 Claude CLI 刷新未恢复。请运行 `claude login` 后重试。";
+"error.claude.delegated_refresh_unavailable" = "Claude OAuth 令牌已过期;委托刷新当前不可用(结果=%@)。";
+"error.claude.delegated_refresh_cooling_down" = "Claude OAuth 令牌已过期,委托刷新正在冷却中。请稍后重试,或运行 `claude login`。";
+"error.claude.delegated_refresh_cli_unavailable" = "Claude OAuth 令牌已过期,且当前无法使用 Claude CLI 做委托刷新。请安装或配置 `claude`,或运行 `claude login`。";
+"error.claude.delegated_refresh_still_unavailable" = "委托 Claude CLI 刷新后,Claude OAuth 令牌仍不可用。请运行 `claude login` 后重试。";
+"error.claude.delegated_refresh_failed" = "Claude OAuth 令牌已过期,且委托 Claude CLI 刷新失败:%@。请运行 `claude login` 后重试。";
+"history.empty_state.for_title" = "暂无 %@ 用量历史数据。";
+"history.empty_state" = "暂无用量历史数据。";
+"history.detail_line.unobserved" = "%@:-";
+"history.detail_line.used" = "%@:已用 %@%%";
+"usage.on_demand" = "按需用量:%@";
+"usage.on_demand.with_limit" = "按需用量:%@ / %@";
+"usage.credits.inline" = "额度:%@";
+"usage.credits.compact" = "额度:%@/%@";
+"usage.last_spend.inline" = "最近支出:%@";
+"Opus" = "Opus";
+"Cancel" = "取消";
+"Vertex AI Login" = "Vertex AI 登录";
+"vertex_ai.login.title" = "Vertex AI 登录";
+"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现在要打开终端吗?";
+"Open Augment (Log Out & Back In)" = "打开 Augment(退出后重新登录)";
+"Historical tracking" = "历史跟踪";
+"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "在本地保存 Codex 用量历史(8 周),用于个性化 Pace 预测。";
+"OpenAI web extras" = "OpenAI 网页扩展";
+"Optional. Turn this on to show code review, usage breakdown, and credits history via chatgpt.com." = "可选。启用后可通过 chatgpt.com 显示代码审查、用量明细和额度历史。";
+"Battery Saver" = "省电模式";
+"Limits background chatgpt.com refreshes to reduce battery and network usage. Dashboard extras may stay stale until you refresh them manually." = "限制 chatgpt.com 的后台刷新,以减少电量和网络消耗。仪表盘附加信息可能会保持旧状态,直到你手动刷新。";
+"Usage source" = "用量来源";
+"Keychain prompt policy" = "钥匙串提示策略";
+"Claude cookies" = "Claude Cookies";
+"Claude cookies are disabled." = "Claude Cookies 已禁用。";
+"Automatic imports browser cookies or stored sessions." = "自动导入浏览器 Cookies 或已保存的会话。";
+"Paste a Cookie header from a cursor.com request." = "粘贴来自 cursor.com 请求的 Cookie 头信息。";
+"Automatic imports browser cookies and WorkOS tokens." = "自动导入浏览器 Cookies 和 WorkOS 令牌。";
+"Paste a Cookie header from app.factory.ai." = "粘贴来自 app.factory.ai 的 Cookie 头信息。";
+"Automatic imports browser cookies from admin.mistral.ai." = "自动导入来自 admin.mistral.ai 的浏览器 Cookies。";
+"Paste a Cookie header captured from the billing page." = "粘贴从账单页面抓取的 Cookie 头信息。";
+"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "粘贴来自 admin.mistral.ai 请求的 Cookie 头信息,必须包含 `ory_session_*` Cookie。";
+"Open Mistral Admin" = "打开 Mistral 管理后台";
+"Automatic imports browser cookies from Model Studio/Bailian." = "自动导入来自 Model Studio / 百炼 的浏览器 Cookies。";
+"Paste a Cookie header from modelstudio.console.alibabacloud.com." = "粘贴来自 modelstudio.console.alibabacloud.com 的 Cookie 头信息。";
+"Gateway region" = "网关区域";
+"API key" = "API 密钥";
+"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "存储在 `~/.codexbar/config.json`。请粘贴你在 Model Studio 中获取的 Coding Plan API 密钥。";
+"Open Coding Plan" = "打开 Coding Plan";
+"Automatic imports browser cookies." = "自动导入浏览器 Cookies。";
+"Paste a Cookie header or cURL capture from the Abacus AI dashboard." = "粘贴来自 Abacus AI 控制台的 Cookie 头信息或 cURL 抓包。";
+"Open Dashboard" = "打开控制台";
+"Paste a Cookie header or cURL capture from the Augment dashboard." = "粘贴来自 Augment 控制台的 Cookie 头信息或 cURL 抓包。";
+"Open Droid in Browser..." = "在浏览器中打开 Droid…";
+"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "存储在 `~/.codexbar/config.json`。请粘贴你的 MiniMax API 密钥。";
+"Choose the MiniMax host (global .io or China mainland .com)." = "选择 MiniMax 主机(全球 `.io` 或中国大陆 `.com`)。";
+"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "存储在 `~/.codexbar/config.json`。你也可以提供 `KILO_API_KEY`,或使用 `~/.local/share/kilo/auth.json`(kilo.access)。";
diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift
index 962b5a61f..ff1cbcb79 100644
--- a/Sources/CodexBar/SessionQuotaNotifications.swift
+++ b/Sources/CodexBar/SessionQuotaNotifications.swift
@@ -49,9 +49,13 @@ final class SessionQuotaNotifier: SessionQuotaNotifying {
case .none:
("", "")
case .depleted:
- ("\(providerName) session depleted", "0% left. Will notify when it's available again.")
+ (
+ localizedUIFormat("notification.session.depleted.title", providerName),
+ localizedUI("notification.session.depleted.body"))
case .restored:
- ("\(providerName) session restored", "Session quota is available again.")
+ (
+ localizedUIFormat("notification.session.restored.title", providerName),
+ localizedUI("notification.session.restored.body"))
}
let providerText = provider.rawValue
diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift
index c4705bcc6..21b7bcc0d 100644
--- a/Sources/CodexBar/SettingsStore+Defaults.swift
+++ b/Sources/CodexBar/SettingsStore+Defaults.swift
@@ -2,7 +2,34 @@ import CodexBarCore
import Foundation
import ServiceManagement
+enum AppLanguage: String, CaseIterable, Identifiable {
+ case system
+ case english
+ case zhHans
+
+ var id: String { self.rawValue }
+
+ var label: String {
+ switch self {
+ case .system:
+ NSLocalizedString("System Default", comment: "")
+ case .english:
+ NSLocalizedString("English", comment: "")
+ case .zhHans:
+ NSLocalizedString("Simplified Chinese", comment: "")
+ }
+ }
+}
+
extension SettingsStore {
+ static func appLanguageFromDefaults(_ userDefaults: UserDefaults) -> AppLanguage {
+ let langs = userDefaults.array(forKey: "AppleLanguages") as? [String]
+ guard let first = langs?.first else { return .system }
+ if first.hasPrefix("zh") { return .zhHans }
+ if first.hasPrefix("en") { return .english }
+ return .system
+ }
+
private static let mergedOverviewSelectionEditedActiveProvidersKey = "mergedOverviewSelectionEditedActiveProviders"
var refreshFrequency: RefreshFrequency {
@@ -523,6 +550,23 @@ extension SettingsStore {
return normalized
}
+ var appLanguage: AppLanguage {
+ get {
+ AppLanguage(rawValue: self.defaultsState.appLanguageRaw) ?? Self.appLanguageFromDefaults(self.userDefaults)
+ }
+ set {
+ self.defaultsState.appLanguageRaw = newValue.rawValue
+ switch newValue {
+ case .system:
+ self.userDefaults.removeObject(forKey: "AppleLanguages")
+ case .english:
+ self.userDefaults.set(["en"], forKey: "AppleLanguages")
+ case .zhHans:
+ self.userDefaults.set(["zh-Hans", "en"], forKey: "AppleLanguages")
+ }
+ }
+ }
+
private static func decodeProviders(_ rawProviders: [String], maxCount: Int? = nil) -> [UsageProvider] {
var providers: [UsageProvider] = []
providers.reserveCapacity(rawProviders.count)
diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift
index 6d3e76e4f..348b51e55 100644
--- a/Sources/CodexBar/SettingsStore.swift
+++ b/Sources/CodexBar/SettingsStore.swift
@@ -28,12 +28,18 @@ enum RefreshFrequency: String, CaseIterable, Identifiable {
var label: String {
switch self {
- case .manual: "Manual"
- case .oneMinute: "1 min"
- case .twoMinutes: "2 min"
- case .fiveMinutes: "5 min"
- case .fifteenMinutes: "15 min"
- case .thirtyMinutes: "30 min"
+ case .manual:
+ NSLocalizedString("Manual", comment: "")
+ case .oneMinute:
+ NSLocalizedString("1 min", comment: "")
+ case .twoMinutes:
+ NSLocalizedString("2 min", comment: "")
+ case .fiveMinutes:
+ NSLocalizedString("5 min", comment: "")
+ case .fifteenMinutes:
+ NSLocalizedString("15 min", comment: "")
+ case .thirtyMinutes:
+ NSLocalizedString("30 min", comment: "")
}
}
}
@@ -52,12 +58,12 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
var label: String {
switch self {
- case .automatic: "Automatic"
- case .primary: "Primary"
- case .secondary: "Secondary"
- case .tertiary: "Tertiary"
- case .extraUsage: "Extra usage"
- case .average: "Average"
+ case .automatic: localizedUI("Automatic")
+ case .primary: localizedUI("Primary")
+ case .secondary: localizedUI("Secondary")
+ case .tertiary: localizedUI("Tertiary")
+ case .extraUsage: localizedUI("Extra usage")
+ case .average: localizedUI("Average")
}
}
}
@@ -210,6 +216,7 @@ extension SettingsStore {
if refreshDefault == nil {
userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency")
}
+ let appLanguageRaw = Self.appLanguageFromDefaults(userDefaults).rawValue
let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false
let debugMenuEnabled = userDefaults.object(forKey: "debugMenuEnabled") as? Bool ?? false
let debugDisableKeychainAccess: Bool = {
@@ -285,6 +292,7 @@ extension SettingsStore {
return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
+ appLanguageRaw: appLanguageRaw,
launchAtLogin: launchAtLogin,
debugMenuEnabled: debugMenuEnabled,
debugDisableKeychainAccess: debugDisableKeychainAccess,
diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift
index c28de8f63..78d273cdb 100644
--- a/Sources/CodexBar/SettingsStoreState.swift
+++ b/Sources/CodexBar/SettingsStoreState.swift
@@ -2,6 +2,7 @@ import Foundation
struct SettingsDefaultsState {
var refreshFrequency: RefreshFrequency
+ var appLanguageRaw: String
var launchAtLogin: Bool
var debugMenuEnabled: Bool
var debugDisableKeychainAccess: Bool
diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
index c716636b2..e5da31042 100644
--- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
+++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
@@ -53,7 +53,7 @@ extension StatusItemController {
guard !didHydrate else { return }
- let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "")
+ let unavailableItem = NSMenuItem(title: localizedUI("No data available"), action: nil, keyEquivalent: "")
unavailableItem.isEnabled = false
unavailableItem.representedObject = chartID
unavailableItem.toolTip = placeholder.toolTip
diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift
index f3c9247c3..70d49ab02 100644
--- a/Sources/CodexBar/StatusItemController+Menu.swift
+++ b/Sources/CodexBar/StatusItemController+Menu.swift
@@ -438,9 +438,9 @@ extension StatusItemController {
activeProviders: enabledProviders,
maxVisibleProviders: Self.maxOverviewProviders)
let message = if resolvedProviders.isEmpty {
- "No providers selected for Overview."
+ NSLocalizedString("No providers selected for Overview.", comment: "")
} else {
- "No overview data available."
+ NSLocalizedString("No overview data available.", comment: "")
}
let item = NSMenuItem(title: message, action: nil, keyEquivalent: "")
item.isEnabled = false
@@ -547,21 +547,23 @@ extension StatusItemController {
menu.addItem(self.makeWrappedSecondaryTextItem(text: text, width: width))
continue
}
- let item = NSMenuItem(title: text, action: nil, keyEquivalent: "")
+ let localizedText = NSLocalizedString(text, comment: "")
+ let item = NSMenuItem(title: localizedText, action: nil, keyEquivalent: "")
item.isEnabled = false
if style == .headline {
let font = NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
- item.attributedTitle = NSAttributedString(string: text, attributes: [.font: font])
+ item.attributedTitle = NSAttributedString(string: localizedText, attributes: [.font: font])
} else if style == .secondary {
let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
item.attributedTitle = NSAttributedString(
- string: text,
+ string: localizedText,
attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor])
}
menu.addItem(item)
case let .action(title, action):
let (selector, represented) = self.selector(for: action)
- let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
+ let localizedTitle = NSLocalizedString(title, comment: "")
+ let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "")
item.target = self
item.representedObject = represented
if let shortcut = self.shortcut(for: action) {
@@ -579,16 +581,16 @@ extension StatusItemController {
let subtitle = self.switchAccountSubtitle(for: targetProvider)
{
item.isEnabled = false
- self.applySubtitle(subtitle, to: item, title: title)
+ self.applySubtitle(subtitle, to: item, title: localizedTitle)
} else if case .addCodexAccount = action,
let subtitle = self.codexAddAccountSubtitle()
{
item.isEnabled = false
- self.applySubtitle(subtitle, to: item, title: title)
+ self.applySubtitle(subtitle, to: item, title: localizedTitle)
}
menu.addItem(item)
case let .submenu(title, systemImageName, submenuItems):
- let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: NSLocalizedString(title, comment: ""), action: nil, keyEquivalent: "")
if let systemImageName,
let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil)
{
@@ -596,10 +598,10 @@ extension StatusItemController {
image.size = NSSize(width: 16, height: 16)
item.image = image
}
- let submenu = NSMenu(title: title)
+ let submenu = NSMenu(title: NSLocalizedString(title, comment: ""))
submenu.autoenablesItems = false
for submenuItem in submenuItems {
- let child = NSMenuItem(title: submenuItem.title, action: nil, keyEquivalent: "")
+ let child = NSMenuItem(title: NSLocalizedString(submenuItem.title, comment: ""), action: nil, keyEquivalent: "")
child.state = submenuItem.isChecked ? .on : .off
child.isEnabled = submenuItem.isEnabled
if let action = submenuItem.action {
@@ -1204,7 +1206,7 @@ extension StatusItemController {
}
private func makeBuyCreditsItem() -> NSMenuItem {
- let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "")
+ let item = NSMenuItem(title: NSLocalizedString("Buy Credits...", comment: ""), action: #selector(self.openCreditsPurchase), keyEquivalent: "")
item.target = self
if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) {
image.isTemplate = true
@@ -1217,7 +1219,7 @@ extension StatusItemController {
@discardableResult
private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool {
guard let submenu = self.makeCreditsHistorySubmenu() else { return false }
- let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: NSLocalizedString("Credits history", comment: ""), action: nil, keyEquivalent: "")
item.isEnabled = true
item.submenu = submenu
menu.addItem(item)
@@ -1227,7 +1229,7 @@ extension StatusItemController {
@discardableResult
private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool {
guard let submenu = self.makeUsageBreakdownSubmenu() else { return false }
- let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: NSLocalizedString("Usage breakdown", comment: ""), action: nil, keyEquivalent: "")
item.isEnabled = true
item.submenu = submenu
menu.addItem(item)
@@ -1237,7 +1239,7 @@ extension StatusItemController {
@discardableResult
private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool {
guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false }
- let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: NSLocalizedString("Usage history (30 days)", comment: ""), action: nil, keyEquivalent: "")
item.isEnabled = true
item.submenu = submenu
menu.addItem(item)
@@ -1264,12 +1266,12 @@ extension StatusItemController {
let submenu = NSMenu()
submenu.delegate = self
- let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "")
+ let titleItem = NSMenuItem(title: NSLocalizedString("MCP details", comment: ""), action: nil, keyEquivalent: "")
titleItem.isEnabled = false
submenu.addItem(titleItem)
if let window = timeLimit.windowLabel {
- let item = NSMenuItem(title: "Window: \(window)", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: String(format: NSLocalizedString("Window: %@", comment: ""), window), action: nil, keyEquivalent: "")
item.isEnabled = false
submenu.addItem(item)
}
@@ -1277,7 +1279,7 @@ extension StatusItemController {
let reset = self.settings.resetTimeDisplayStyle == .absolute
? UsageFormatter.resetDescription(from: resetTime)
: UsageFormatter.resetCountdownDescription(from: resetTime)
- let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: String(format: NSLocalizedString("Resets: %@", comment: ""), reset), action: nil, keyEquivalent: "")
item.isEnabled = false
submenu.addItem(item)
}
diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift
index 74bf8fae7..7d204c1f6 100644
--- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift
+++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift
@@ -68,7 +68,7 @@ final class ProviderSwitcherView: NSView {
Segment(
selection: .overview,
image: overviewIcon,
- title: "Overview"),
+ title: NSLocalizedString("menu.overview", comment: "")),
at: 0)
}
self.segments = segments
diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
index 6cfed4206..40669347d 100644
--- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
+++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
@@ -14,7 +14,7 @@ extension StatusItemController {
guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false }
let item = self.makeMenuCardItem(
HStack(spacing: 0) {
- Text("Subscription Utilization")
+ Text(localizedUI("Subscription Utilization"))
.font(.system(size: NSFont.menuFont(ofSize: 0).pointSize))
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift
index 94d1ed565..7a67a484f 100644
--- a/Sources/CodexBar/UsagePaceText.swift
+++ b/Sources/CodexBar/UsagePaceText.swift
@@ -12,9 +12,9 @@ enum UsagePaceText {
static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String {
let detail = self.weeklyDetail(pace: pace, now: now)
if let rightLabel = detail.rightLabel {
- return "Pace: \(detail.leftLabel) · \(rightLabel)"
+ return String(format: NSLocalizedString("usage.pace.summary.detail", comment: ""), detail.leftLabel, rightLabel)
}
- return "Pace: \(detail.leftLabel)"
+ return String(format: NSLocalizedString("usage.pace.summary", comment: ""), detail.leftLabel)
}
static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail {
@@ -29,28 +29,30 @@ enum UsagePaceText {
let deltaValue = Int(abs(pace.deltaPercent).rounded())
switch pace.stage {
case .onTrack:
- return "On pace"
+ return NSLocalizedString("usage.pace.on_track", comment: "")
case .slightlyAhead, .ahead, .farAhead:
- return "\(deltaValue)% in deficit"
+ return String(format: NSLocalizedString("usage.pace.in_deficit", comment: ""), deltaValue)
case .slightlyBehind, .behind, .farBehind:
- return "\(deltaValue)% in reserve"
+ return String(format: NSLocalizedString("usage.pace.in_reserve", comment: ""), deltaValue)
}
}
private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? {
let etaLabel: String?
if pace.willLastToReset {
- etaLabel = "Lasts until reset"
+ etaLabel = NSLocalizedString("usage.pace.lasts_until_reset", comment: "")
} else if let etaSeconds = pace.etaSeconds {
let etaText = Self.durationText(seconds: etaSeconds, now: now)
- etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)"
+ etaLabel = etaText == NSLocalizedString("time.now", comment: "")
+ ? NSLocalizedString("usage.pace.runs_out_now", comment: "")
+ : String(format: NSLocalizedString("usage.pace.runs_out_in", comment: ""), etaText)
} else {
etaLabel = nil
}
guard let runOutProbability = pace.runOutProbability else { return etaLabel }
let roundedRisk = self.roundedRiskPercent(runOutProbability)
- let riskLabel = "≈ \(roundedRisk)% run-out risk"
+ let riskLabel = String(format: NSLocalizedString("usage.pace.run_out_risk", comment: ""), roundedRisk)
if let etaLabel {
return "\(etaLabel) · \(riskLabel)"
}
@@ -60,8 +62,10 @@ enum UsagePaceText {
private static func durationText(seconds: TimeInterval, now: Date) -> String {
let date = now.addingTimeInterval(seconds)
let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now)
- if countdown == "now" { return "now" }
- if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) }
+ let localizedNow = NSLocalizedString("time.now", comment: "")
+ if countdown == localizedNow { return localizedNow }
+ let inPrefix = NSLocalizedString("time.in_prefix", comment: "")
+ if countdown.hasPrefix(inPrefix) { return String(countdown.dropFirst(inPrefix.count)) }
return countdown
}
diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift
index 522416898..468e8ab03 100644
--- a/Sources/CodexBar/UsageStoreSupport.swift
+++ b/Sources/CodexBar/UsageStoreSupport.swift
@@ -18,12 +18,12 @@ enum ProviderStatusIndicator: String {
var label: String {
switch self {
- case .none: "Operational"
- case .minor: "Partial outage"
- case .major: "Major outage"
- case .critical: "Critical issue"
- case .maintenance: "Maintenance"
- case .unknown: "Status unknown"
+ case .none: localizedUI("Operational")
+ case .minor: localizedUI("Partial outage")
+ case .major: localizedUI("Major outage")
+ case .critical: localizedUI("Critical issue")
+ case .maintenance: localizedUI("Maintenance")
+ case .unknown: localizedUI("Status unknown")
}
}
}
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift
index 166c331ad..184f91a1f 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift
@@ -19,7 +19,7 @@ public enum ClaudePeakHours: Sendable {
let minute = components.minute,
let weekday = components.weekday
else {
- return Status(isPeak: false, label: "Off-peak")
+ return Status(isPeak: false, label: NSLocalizedString("claude.peak.off_peak", value: "Off-peak", comment: ""))
}
let isWeekday = weekday >= 2 && weekday <= 6
@@ -32,7 +32,9 @@ public enum ClaudePeakHours: Sendable {
let remaining = peakEndMinutes - nowMinutes
return Status(
isPeak: true,
- label: "Peak · ends in \(self.formatDuration(minutes: remaining))")
+ label: String(
+ format: NSLocalizedString("claude.peak.ends_in", value: "Peak · ends in %@", comment: ""),
+ self.formatDuration(minutes: remaining)))
}
let nextPeak = self.nextPeakStart(after: date, calendar: calendar)
@@ -40,7 +42,9 @@ public enum ClaudePeakHours: Sendable {
let minutes = max(Int(seconds / 60), 0)
return Status(
isPeak: false,
- label: "Off-peak · peak in \(self.formatDuration(minutes: minutes))")
+ label: String(
+ format: NSLocalizedString("claude.peak.next_peak_in", value: "Off-peak · peak in %@", comment: ""),
+ self.formatDuration(minutes: minutes)))
}
private static func nextPeakStart(after date: Date, calendar: Calendar) -> Date {
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift
index 20a56e502..42fe454ce 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift
@@ -33,11 +33,11 @@ public enum ClaudeStatusProbeError: LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .claudeNotInstalled:
- "Claude CLI is not installed or not on PATH."
+ NSLocalizedString("error.claude.cli_not_installed", comment: "")
case let .parseFailed(msg):
- "Could not parse Claude usage: \(msg)"
+ String(format: NSLocalizedString("error.claude.parse_failed", comment: ""), msg)
case .timedOut:
- "Claude usage probe timed out."
+ NSLocalizedString("error.claude.timed_out", comment: "")
}
}
}
@@ -421,33 +421,30 @@ public struct ClaudeStatusProbe: Sendable {
return trimmed.isEmpty ? nil : trimmed
}
if let folderHint {
- return """
- Claude CLI is waiting for a folder trust prompt (\(folderHint)). CodexBar tries to auto-accept this, \
- but if it keeps appearing run: `cd "\(folderHint)" && claude` and choose “Yes, proceed”, then retry.
- """
+ return String(
+ format: NSLocalizedString("error.claude.folder_trust_prompt.with_folder", comment: ""),
+ folderHint,
+ folderHint)
}
- return """
- Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps \
- appearing open `claude` once, choose “Yes, proceed”, then retry.
- """
+ return NSLocalizedString("error.claude.folder_trust_prompt", comment: "")
}
if lower.contains("token_expired") || lower.contains("token has expired") {
- return "Claude CLI token expired. Run `claude login` to refresh."
+ return NSLocalizedString("error.claude.token_expired", comment: "")
}
if lower.contains("authentication_error") {
- return "Claude CLI authentication error. Run `claude login`."
+ return NSLocalizedString("error.claude.authentication_error", comment: "")
}
if lower.contains("rate_limit_error")
|| lower.contains("rate limited")
|| compact.contains("ratelimited")
{
- return "Claude CLI usage endpoint is rate limited right now. Please try again later."
+ return NSLocalizedString("error.claude.rate_limited", comment: "")
}
if lower.contains("failed to load usage data") {
- return "Claude CLI could not load usage data. Open the CLI and retry `/usage`."
+ return NSLocalizedString("error.claude.could_not_load_usage", comment: "")
}
if compact.contains("failedtoloadusagedata") {
- return "Claude CLI could not load usage data. Open the CLI and retry `/usage`."
+ return NSLocalizedString("error.claude.could_not_load_usage", comment: "")
}
return nil
}
@@ -754,7 +751,7 @@ public struct ClaudeStatusProbe: Sendable {
let type = (error["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if type == "rate_limit_error" {
- return "Claude CLI usage endpoint is rate limited right now. Please try again later."
+ return NSLocalizedString("error.claude.rate_limited", comment: "")
}
var parts: [String] = []
@@ -765,9 +762,9 @@ public struct ClaudeStatusProbe: Sendable {
let hint = parts.joined(separator: " ")
if let code, code.lowercased().contains("token") {
- return "\(hint). Run `claude login` to refresh."
+ return String(format: NSLocalizedString("error.claude.error_with_login_hint", comment: ""), hint)
}
- return "Claude CLI error: \(hint)"
+ return String(format: NSLocalizedString("error.claude.generic_error", comment: ""), hint)
}
// MARK: - Process helpers
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
index 82d919865..492737183 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
@@ -51,9 +51,9 @@ public enum ClaudeUsageError: LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .claudeNotInstalled:
- "Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview."
+ NSLocalizedString("error.claude.not_installed", value: "Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview.", comment: "")
case let .parseFailed(details):
- "Could not parse Claude usage: \(details)"
+ String(format: NSLocalizedString("error.claude.parse_failed", value: "Could not parse Claude usage: %@", comment: ""), details)
case let .oauthFailed(details):
details
}
@@ -178,15 +178,14 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
{
guard policy.isApplicable else { return }
if policy.mode == .never {
- throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.")
+ throw ClaudeUsageError.oauthFailed(NSLocalizedString("error.claude.delegated_refresh_disabled", value: "Delegated refresh is disabled by 'never' keychain policy.", comment: ""))
}
if policy.mode == .onlyOnUserAction,
policy.interaction != .userInitiated,
!allowBackgroundDelegatedRefresh
{
throw ClaudeUsageError.oauthFailed(
- "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy "
- + "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.")
+ NSLocalizedString("error.claude.oauth_background_repair_suppressed", value: "Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.", comment: ""))
}
}
@@ -310,8 +309,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
private func loadAfterDelegatedRefresh(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {
guard allowDelegatedRetry else {
throw ClaudeUsageError.oauthFailed(
- "Claude OAuth token expired and delegated Claude CLI refresh did not recover. "
- + "Run `claude login`, then retry.")
+ NSLocalizedString("error.claude.delegated_refresh_did_not_recover", value: "Claude OAuth token expired and delegated Claude CLI refresh did not recover. Run `claude login`, then retry.", comment: ""))
}
try Task.checkCancellation()
@@ -334,8 +332,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
switch delegatedOutcome {
case .skippedByCooldown, .cliUnavailable:
throw ClaudeUsageError.oauthFailed(
- "Claude OAuth token expired; delegated refresh is unavailable (outcome="
- + "\(ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome))).")
+ String(
+ format: NSLocalizedString("error.claude.delegated_refresh_unavailable", value: "Claude OAuth token expired; delegated refresh is unavailable (outcome=%@).", comment: ""),
+ ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome)))
case .attemptedSucceeded, .attemptedFailed:
break
}
@@ -773,17 +772,15 @@ extension ClaudeUsageFetcher {
_ = retryError
switch outcome {
case .skippedByCooldown:
- return "Claude OAuth token expired and delegated refresh is cooling down. "
- + "Please retry shortly, or run `claude login`."
+ return NSLocalizedString("error.claude.delegated_refresh_cooling_down", value: "Claude OAuth token expired and delegated refresh is cooling down. Please retry shortly, or run `claude login`.", comment: "")
case .cliUnavailable:
- return "Claude OAuth token expired and Claude CLI is not available for delegated refresh. "
- + "Install/configure `claude`, or run `claude login`."
+ return NSLocalizedString("error.claude.delegated_refresh_cli_unavailable", value: "Claude OAuth token expired and Claude CLI is not available for delegated refresh. Install/configure `claude`, or run `claude login`.", comment: "")
case .attemptedSucceeded:
- return "Claude OAuth token is still unavailable after delegated Claude CLI refresh. "
- + "Run `claude login`, then retry."
+ return NSLocalizedString("error.claude.delegated_refresh_still_unavailable", value: "Claude OAuth token is still unavailable after delegated Claude CLI refresh. Run `claude login`, then retry.", comment: "")
case let .attemptedFailed(message):
- return "Claude OAuth token expired and delegated Claude CLI refresh failed: \(message). "
- + "Run `claude login`, then retry."
+ return String(
+ format: NSLocalizedString("error.claude.delegated_refresh_failed", value: "Claude OAuth token expired and delegated Claude CLI refresh failed: %@. Run `claude login`, then retry.", comment: ""),
+ message)
}
}
diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
index e9fe1c7b6..1d2748933 100644
--- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
@@ -529,15 +529,15 @@ public enum CursorStatusProbeError: LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .notLoggedIn:
- "Not logged in to Cursor. Please log in via the CodexBar menu."
+ NSLocalizedString("error.cursor.not_logged_in", value: "Not logged in to Cursor. Please log in via the CodexBar menu.", comment: "")
case let .networkError(msg):
"Cursor API error: \(msg)"
case let .parseFailed(msg):
"Could not parse Cursor usage: \(msg)"
case .noSessionCookie:
- "No Cursor session found. Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). "
- + "If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. "
- + "You can also sign in to Cursor from the CodexBar menu (Add / switch account)."
+ String(
+ format: NSLocalizedString("error.cursor.no_session", value: "No Cursor session found. Please log in to cursor.com in %@. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account).", comment: ""),
+ cursorCookieImportOrder.loginHint)
}
}
}
diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift
index 775e109bf..ef03234f1 100644
--- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift
@@ -41,7 +41,9 @@ public struct KimiK2UsageSummary: Sendable {
usedPercent: usedPercent,
windowMinutes: nil,
resetsAt: nil,
- resetDescription: total > 0 ? "Credits: \(usedText)/\(totalText)" : nil)
+ resetDescription: total > 0
+ ? String(format: NSLocalizedString("usage.credits.compact", comment: ""), usedText, totalText)
+ : nil)
let identity = ProviderIdentitySnapshot(
providerID: .kimik2,
accountEmail: nil,
diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift
index 85c011d6d..7c43e50e3 100644
--- a/Sources/CodexBarCore/UsageFormatter.swift
+++ b/Sources/CodexBarCore/UsageFormatter.swift
@@ -9,13 +9,15 @@ public enum UsageFormatter {
public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String {
let percent = showUsed ? used : remaining
let clamped = min(100, max(0, percent))
- let suffix = showUsed ? "used" : "left"
+ let suffix = showUsed
+ ? NSLocalizedString("usage.percent.used", value: "used", comment: "")
+ : NSLocalizedString("usage.percent.left", value: "left", comment: "")
return String(format: "%.0f%% %@", clamped, suffix)
}
public static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, date.timeIntervalSince(now))
- if seconds < 1 { return "now" }
+ if seconds < 1 { return NSLocalizedString("time.now", value: "now", comment: "") }
let totalMinutes = max(1, Int(ceil(seconds / 60.0)))
let days = totalMinutes / (24 * 60)
@@ -23,14 +25,18 @@ public enum UsageFormatter {
let minutes = totalMinutes % 60
if days > 0 {
- if hours > 0 { return "in \(days)d \(hours)h" }
- return "in \(days)d"
+ if hours > 0 {
+ return String(format: NSLocalizedString("time.in_days_hours", value: "in %dd %dh", comment: ""), days, hours)
+ }
+ return String(format: NSLocalizedString("time.in_days", value: "in %dd", comment: ""), days)
}
if hours > 0 {
- if minutes > 0 { return "in \(hours)h \(minutes)m" }
- return "in \(hours)h"
+ if minutes > 0 {
+ return String(format: NSLocalizedString("time.in_hours_minutes", value: "in %dh %dm", comment: ""), hours, minutes)
+ }
+ return String(format: NSLocalizedString("time.in_hours", value: "in %dh", comment: ""), hours)
}
- return "in \(totalMinutes)m"
+ return String(format: NSLocalizedString("time.in_minutes", value: "in %dm", comment: ""), totalMinutes)
}
public static func resetDescription(from date: Date, now: Date = .init()) -> String {
@@ -42,7 +48,7 @@ public enum UsageFormatter {
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now),
calendar.isDate(date, inSameDayAs: tomorrow)
{
- return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))"
+ return String(format: NSLocalizedString("time.tomorrow_at", value: "tomorrow, %@", comment: ""), date.formatted(date: .omitted, time: .shortened))
}
return date.formatted(date: .abbreviated, time: .shortened)
}
@@ -56,14 +62,14 @@ public enum UsageFormatter {
let text = style == .countdown
? self.resetCountdownDescription(from: date, now: now)
: self.resetDescription(from: date, now: now)
- return "Resets \(text)"
+ return String(format: NSLocalizedString("usage.resets", value: "Resets %@", comment: ""), text)
}
if let desc = window.resetDescription {
let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.lowercased().hasPrefix("resets") { return trimmed }
- return "Resets \(trimmed)"
+ return String(format: NSLocalizedString("usage.resets", value: "Resets %@", comment: ""), trimmed)
}
return nil
}
@@ -71,24 +77,24 @@ public enum UsageFormatter {
public static func updatedString(from date: Date, now: Date = .init()) -> String {
let delta = now.timeIntervalSince(date)
if abs(delta) < 60 {
- return "Updated just now"
+ return NSLocalizedString("usage.updated.just_now", value: "Updated just now", comment: "")
}
if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 {
#if os(macOS)
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
- return "Updated \(rel.localizedString(for: date, relativeTo: now))"
+ return String(format: NSLocalizedString("usage.updated.relative", value: "Updated %@", comment: ""), rel.localizedString(for: date, relativeTo: now))
#else
let seconds = max(0, Int(now.timeIntervalSince(date)))
if seconds < 3600 {
let minutes = max(1, seconds / 60)
- return "Updated \(minutes)m ago"
+ return String(format: NSLocalizedString("usage.updated.minutes_ago", value: "Updated %dm ago", comment: ""), minutes)
}
let wholeHours = max(1, seconds / 3600)
- return "Updated \(wholeHours)h ago"
+ return String(format: NSLocalizedString("usage.updated.hours_ago", value: "Updated %dh ago", comment: ""), wholeHours)
#endif
} else {
- return "Updated \(date.formatted(date: .omitted, time: .shortened))"
+ return String(format: NSLocalizedString("usage.updated.at_time", value: "Updated %@", comment: ""), date.formatted(date: .omitted, time: .shortened))
}
}
@@ -99,7 +105,7 @@ public enum UsageFormatter {
// Use explicit locale for consistent formatting on all systems
number.locale = Locale(identifier: "en_US_POSIX")
let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)
- return "\(formatted) left"
+ return String(format: NSLocalizedString("credits.left", value: "%@ left", comment: ""), formatted)
}
/// Formats a USD value with proper negative handling and thousand separators.
diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
index 4948cb31b..05b7a77c0 100644
--- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
@@ -117,6 +117,64 @@ struct SettingsStoreAdditionalTests {
#expect(SettingsStore.hasAnyTokenCostUsageSources(env: env, fileManager: fm))
}
+ @Test
+ func `app language persists and clears AppleLanguages override`() throws {
+ let suite = "SettingsStoreAdditionalTests-app-language"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore(),
+ codexCookieStore: InMemoryCookieHeaderStore(),
+ claudeCookieStore: InMemoryCookieHeaderStore(),
+ cursorCookieStore: InMemoryCookieHeaderStore(),
+ opencodeCookieStore: InMemoryCookieHeaderStore(),
+ factoryCookieStore: InMemoryCookieHeaderStore(),
+ minimaxCookieStore: InMemoryMiniMaxCookieStore(),
+ minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
+ kimiTokenStore: InMemoryKimiTokenStore(),
+ kimiK2TokenStore: InMemoryKimiK2TokenStore(),
+ augmentCookieStore: InMemoryCookieHeaderStore(),
+ ampCookieStore: InMemoryCookieHeaderStore(),
+ copilotTokenStore: InMemoryCopilotTokenStore(),
+ tokenAccountStore: InMemoryTokenAccountStore())
+
+ #expect(settings.appLanguage == .system)
+
+ settings.appLanguage = .zhHans
+ #expect(settings.appLanguage == .zhHans)
+ #expect((defaults.array(forKey: "AppleLanguages") as? [String]) == ["zh-Hans", "en"])
+
+ let reloaded = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore(),
+ codexCookieStore: InMemoryCookieHeaderStore(),
+ claudeCookieStore: InMemoryCookieHeaderStore(),
+ cursorCookieStore: InMemoryCookieHeaderStore(),
+ opencodeCookieStore: InMemoryCookieHeaderStore(),
+ factoryCookieStore: InMemoryCookieHeaderStore(),
+ minimaxCookieStore: InMemoryMiniMaxCookieStore(),
+ minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
+ kimiTokenStore: InMemoryKimiTokenStore(),
+ kimiK2TokenStore: InMemoryKimiK2TokenStore(),
+ augmentCookieStore: InMemoryCookieHeaderStore(),
+ ampCookieStore: InMemoryCookieHeaderStore(),
+ copilotTokenStore: InMemoryCopilotTokenStore(),
+ tokenAccountStore: InMemoryTokenAccountStore())
+
+ #expect(reloaded.appLanguage == .zhHans)
+
+ reloaded.appLanguage = .system
+ #expect(reloaded.appLanguage == .system)
+ #expect(defaults.array(forKey: "AppleLanguages") == nil)
+ }
+
private static func makeSettingsStore(suite: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)