Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Sources/AgentBar/Localization/I18n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum I18n {
let loading: String
let notRefreshed: String
let lastRefreshPrefix: String
let refreshNow: String
let aboutUs: String
let quit: String
let currentSession: String
Expand All @@ -38,6 +39,10 @@ enum I18n {
let missingTokenUsageResult: String
let expectedQuotaWindows: String
let availableResetCredits: String
let resetCreditDefaultTitle: String
let resetCreditNeverExpires: String
let resetCreditDetailsUnavailable: String
let noAvailableResetCredits: String
let todayTokenUsage: String
let yesterdayTokenUsage: String
let totalTokenUsage: String
Expand Down Expand Up @@ -144,6 +149,24 @@ enum I18n {
}
}

func resetCreditExpiresOn(_ dateText: String) -> String {
switch I18n.language {
case .simplifiedChinese:
return "将于 \(dateText) 到期"
case .english:
return "Expires on \(dateText)"
}
}

func additionalResetCredits(_ count: Int) -> String {
switch I18n.language {
case .simplifiedChinese:
return "另有 \(count) 次重置"
case .english:
return "\(count) more reset\(count == 1 ? "" : "s")"
}
}

func durationTitle(minutes: Int) -> String {
switch minutes {
case 300:
Expand Down Expand Up @@ -209,6 +232,7 @@ enum I18n {
loading: "loading...",
notRefreshed: "Not refreshed",
lastRefreshPrefix: "Last refresh",
refreshNow: "Refresh now",
aboutUs: "About us",
quit: "Quit",
currentSession: "Current session",
Expand All @@ -233,6 +257,10 @@ enum I18n {
missingTokenUsageResult: "Missing token usage result.",
expectedQuotaWindows: "Expected primary and secondary quota windows.",
availableResetCredits: "Available resets:",
resetCreditDefaultTitle: "Full reset (Weekly + 5 hr)",
resetCreditNeverExpires: "Does not expire",
resetCreditDetailsUnavailable: "Details unavailable",
noAvailableResetCredits: "No reset credits available",
todayTokenUsage: "Today tokens",
yesterdayTokenUsage: "Yesterday tokens",
totalTokenUsage: "Total tokens",
Expand All @@ -250,6 +278,7 @@ enum I18n {
loading: "加载中...",
notRefreshed: "尚未刷新",
lastRefreshPrefix: "上次刷新",
refreshNow: "立即刷新",
aboutUs: "关于我们",
quit: "退出",
currentSession: "当前会话",
Expand All @@ -274,6 +303,10 @@ enum I18n {
missingTokenUsageResult: "缺少 token 消耗结果。",
expectedQuotaWindows: "需要 primary 和 secondary 两个额度窗口。",
availableResetCredits: "可用重置次数:",
resetCreditDefaultTitle: "完整重置(周 + 5 小时)",
resetCreditNeverExpires: "不会到期",
resetCreditDetailsUnavailable: "暂无重置详情",
noAvailableResetCredits: "暂无可用重置次数",
todayTokenUsage: "今日 Token 数",
yesterdayTokenUsage: "昨日 Token 数",
totalTokenUsage: "累计 Token 数",
Expand Down
26 changes: 26 additions & 0 deletions Sources/AgentBar/Models/QuotaModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,37 @@ struct QuotaWindow: Identifiable, Sendable {
let resetsAt: Date?
}

struct RateLimitResetCredit: Identifiable, Sendable {
let id: String
let resetType: String
let status: String
let grantedAt: Date?
let expiresAt: Date?
let title: String?
let detailText: String?
}

struct QuotaSnapshot: Sendable {
var primary: QuotaWindow
var secondary: QuotaWindow
var availableResetCredits: Int?
var resetCredits: [RateLimitResetCredit]?
var lastUpdated: Date

func preservingResetCreditDetails(from previous: QuotaSnapshot?) -> QuotaSnapshot {
guard
resetCredits == nil,
availableResetCredits != 0,
previous?.availableResetCredits == availableResetCredits,
let previousCredits = previous?.resetCredits
else {
return self
}

var merged = self
merged.resetCredits = previousCredits
return merged
}
}

struct TokenUsageDay: Identifiable, Sendable {
Expand Down
26 changes: 5 additions & 21 deletions Sources/AgentBar/RPC/CodexAppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class CodexAppServerClient {
private var nextRequestID = 1
private var pending: [Int: Completion] = [:]
private var initialized = false
private var selectedCodexPath: String?

func start(completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
queue.async {
Expand Down Expand Up @@ -79,6 +80,7 @@ final class CodexAppServerClient {

func checkMinimumResetCreditsVersion(completion: @escaping @Sendable (Result<CodexCLIVersion, Error>) -> Void) {
queue.async {
self.selectedCodexPath = nil
guard let codexPath = Self.resolveCodexCLIPath() else {
completion(.failure(QuotaError.codexCLINotFound))
return
Expand All @@ -94,6 +96,7 @@ final class CodexAppServerClient {
return
}

self.selectedCodexPath = codexPath
completion(.success(version))
} catch {
completion(.failure(error))
Expand All @@ -102,7 +105,7 @@ final class CodexAppServerClient {
}

private func launchProcess() throws {
guard let codexPath = Self.resolveCodexCLIPath() else {
guard let codexPath = selectedCodexPath ?? Self.resolveCodexCLIPath() else {
throw QuotaError.codexCLINotFound
}

Expand Down Expand Up @@ -333,26 +336,7 @@ final class CodexAppServerClient {
}

private static func resolveCodexCLIPath() -> String? {
let fileManager = FileManager.default
let environmentPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
let pathCandidates = environmentPath
.split(separator: ":")
.map { String($0) + "/codex" }

var candidates = pathCandidates + [
"/opt/homebrew/bin/codex",
"/usr/local/bin/codex",
]

let home = fileManager.homeDirectoryForCurrentUser.path
let nvmRoot = "\(home)/.nvm/versions/node"
if let versions = try? fileManager.contentsOfDirectory(atPath: nvmRoot) {
candidates += versions
.sorted(by: >)
.map { "\(nvmRoot)/\($0)/bin/codex" }
}

return candidates.first { fileManager.isExecutableFile(atPath: $0) }
CodexCLIResolver.resolve()
}

private func prettyPrintedSanitizedJSON(_ object: Any) -> String {
Expand Down
80 changes: 80 additions & 0 deletions Sources/AgentBar/RPC/CodexCLIResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import AppKit
import Foundation

struct CodexCLIResolver {
private static let codexBundleIdentifier = "com.openai.codex"
private static let bundledAppNames = ["ChatGPT.app", "Codex.app"]
private static let bundledCLIComponents = ["Contents", "Resources", "codex"]

static func resolve(
fileManager: FileManager = .default,
environmentPath: String? = ProcessInfo.processInfo.environment["PATH"],
homeDirectory: URL? = nil,
registeredApplicationURL: URL? = registeredCodexApplicationURL(),
applicationDirectories: [URL]? = nil,
fallbackBinDirectories: [URL]? = nil
) -> String? {
let homeDirectory = homeDirectory ?? fileManager.homeDirectoryForCurrentUser
let applicationDirectories = applicationDirectories ?? [
URL(fileURLWithPath: "/Applications", isDirectory: true),
homeDirectory.appendingPathComponent("Applications", isDirectory: true),
]

let registeredApplicationCandidates: [URL] = registeredApplicationURL.map {
[bundledCLIURL(in: $0)]
} ?? []
let standardApplicationCandidates: [URL] = bundledAppNames.flatMap { appName in
applicationDirectories.map { directory in
bundledCLIURL(in: directory.appendingPathComponent(appName, isDirectory: true))
}
}
let bundledCandidates = registeredApplicationCandidates + standardApplicationCandidates

let pathCandidates = (environmentPath ?? "")
.split(separator: ":")
.map { URL(fileURLWithPath: String($0), isDirectory: true).appendingPathComponent("codex") }

let fallbackBinDirectories = fallbackBinDirectories ?? [
URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true),
URL(fileURLWithPath: "/usr/local/bin", isDirectory: true),
]
let fallbackCandidates = fallbackBinDirectories.map { $0.appendingPathComponent("codex") }

let nvmRoot = homeDirectory
.appendingPathComponent(".nvm", isDirectory: true)
.appendingPathComponent("versions", isDirectory: true)
.appendingPathComponent("node", isDirectory: true)
let nvmCandidates = ((try? fileManager.contentsOfDirectory(atPath: nvmRoot.path)) ?? [])
.sorted(by: >)
.map {
nvmRoot
.appendingPathComponent($0, isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent("codex")
}

var visitedPaths = Set<String>()
for candidate in bundledCandidates + pathCandidates + fallbackCandidates + nvmCandidates {
let path = candidate.standardizedFileURL.path
guard visitedPaths.insert(path).inserted else {
continue
}

if fileManager.isExecutableFile(atPath: path) {
return path
}
}

return nil
}

private static func registeredCodexApplicationURL() -> URL? {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: codexBundleIdentifier)
}

private static func bundledCLIURL(in applicationURL: URL) -> URL {
bundledCLIComponents.reduce(applicationURL) { url, component in
url.appendingPathComponent(component)
}
}
}
84 changes: 80 additions & 4 deletions Sources/AgentBar/Services/CodexRateLimitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct CodexRateLimitService {
}
}

private func parseRateLimitResponse(_ response: CodexAppServerClient.JSONDictionary) throws -> QuotaSnapshot {
func parseRateLimitResponse(_ response: CodexAppServerClient.JSONDictionary) throws -> QuotaSnapshot {
guard let result = response["result"] as? CodexAppServerClient.JSONDictionary else {
throw QuotaError.parsingFailed(I18n.current.missingRateLimitResult)
}
Expand All @@ -40,10 +40,12 @@ struct CodexRateLimitService {
}

let selected = selectMenuWindows(from: windows)
let resetCredits = resetCreditsSummary(from: result)
let snapshot = QuotaSnapshot(
primary: selected.primary,
secondary: selected.secondary,
availableResetCredits: resetCreditsAvailableCount(from: result),
availableResetCredits: resetCredits.availableCount,
resetCredits: resetCredits.credits,
lastUpdated: Date()
)

Expand Down Expand Up @@ -120,12 +122,70 @@ struct CodexRateLimitService {
return nil
}

private func resetCreditsAvailableCount(from result: CodexAppServerClient.JSONDictionary) -> Int? {
private func resetCreditsSummary(
from result: CodexAppServerClient.JSONDictionary
) -> (availableCount: Int?, credits: [RateLimitResetCredit]?) {
guard let resetCredits = result["rateLimitResetCredits"] as? CodexAppServerClient.JSONDictionary else {
return (nil, nil)
}

let availableCount = intValue(resetCredits["availableCount"])
guard let rawCredits = resetCredits["credits"], !(rawCredits is NSNull) else {
return (availableCount, nil)
}

guard let creditRows = rawCredits as? [Any] else {
return (availableCount, nil)
}

let credits = creditRows.enumerated().compactMap { index, rawCredit -> RateLimitResetCredit? in
guard let credit = rawCredit as? CodexAppServerClient.JSONDictionary else {
return nil
}

let grantedAt = doubleValue(credit["grantedAt"])
.map(Date.init(timeIntervalSince1970:))
let expiresAt = doubleValue(credit["expiresAt"])
.map(Date.init(timeIntervalSince1970:))
let fallbackID = "reset-credit-\(index)-\(grantedAt?.timeIntervalSince1970 ?? 0)"

return RateLimitResetCredit(
id: stringValue(credit["id"]) ?? fallbackID,
resetType: stringValue(credit["resetType"]) ?? "unknown",
status: stringValue(credit["status"]) ?? "unknown",
grantedAt: grantedAt,
expiresAt: expiresAt,
title: stringValue(credit["title"]),
detailText: stringValue(credit["description"])
)
}
.sorted { lhs, rhs in
switch (lhs.expiresAt, rhs.expiresAt) {
case let (lhsDate?, rhsDate?):
if lhsDate == rhsDate {
return lhs.id < rhs.id
}

return lhsDate < rhsDate
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
return lhs.id < rhs.id
}
}

return (availableCount, credits)
}

private func stringValue(_ value: Any?) -> String? {
guard let value = value as? String else {
return nil
}

return intValue(resetCredits["availableCount"])
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private func doubleValue(_ value: Any?) -> Double? {
Expand Down Expand Up @@ -184,6 +244,7 @@ struct CodexRateLimitService {
"primary": printableWindow(snapshot.primary, formatter: formatter),
"secondary": printableWindow(snapshot.secondary, formatter: formatter),
"availableResetCredits": snapshot.availableResetCredits ?? NSNull(),
"resetCredits": snapshot.resetCredits?.map { printableResetCredit($0, formatter: formatter) } ?? NSNull(),
"lastUpdated": formatter.string(from: snapshot.lastUpdated),
]

Expand All @@ -203,4 +264,19 @@ struct CodexRateLimitService {
"resetsAt": window.resetsAt.map { formatter.string(from: $0) } ?? NSNull(),
]
}

private func printableResetCredit(
_ credit: RateLimitResetCredit,
formatter: ISO8601DateFormatter
) -> [String: Any] {
[
"id": credit.id,
"resetType": credit.resetType,
"status": credit.status,
"grantedAt": credit.grantedAt.map { formatter.string(from: $0) } ?? NSNull(),
"expiresAt": credit.expiresAt.map { formatter.string(from: $0) } ?? NSNull(),
"title": credit.title ?? NSNull(),
"description": credit.detailText ?? NSNull(),
]
}
}
Loading