diff --git a/README.md b/README.md
index 757fbf030..a86fa9063 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.
-Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
+Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Qwen Code, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
@@ -38,6 +38,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
+- [Qwen Code](docs/qwencode.md) — Local Qwen Code session logs; daily request usage.
- [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows.
- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit.
- [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals.
diff --git a/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift b/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift
new file mode 100644
index 000000000..19c382a1b
--- /dev/null
+++ b/Sources/CodexBar/Providers/QwenCode/QwenCodeProviderImplementation.swift
@@ -0,0 +1,40 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct QwenCodeProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .qwencode
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "local" }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.qwenCodeDailyRequestLimit
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .qwencode(context.settings.qwencodeSettingsSnapshot())
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "qwencode-daily-request-limit",
+ title: "Daily request limit",
+ subtitle: "Used to compute the daily usage percentage.",
+ kind: .plain,
+ placeholder: "2000",
+ binding: context.stringBinding(\.qwenCodeDailyRequestLimit),
+ actions: [],
+ isVisible: nil,
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift b/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift
new file mode 100644
index 000000000..2dde9b5e0
--- /dev/null
+++ b/Sources/CodexBar/Providers/QwenCode/QwenCodeSettingsStore.swift
@@ -0,0 +1,15 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ func qwencodeSettingsSnapshot() -> ProviderSettingsSnapshot.QwenCodeProviderSettings {
+ ProviderSettingsSnapshot.QwenCodeProviderSettings(
+ dailyRequestLimit: self.qwencodeRequestLimitValue())
+ }
+
+ private func qwencodeRequestLimitValue() -> Int? {
+ let raw = self.qwenCodeDailyRequestLimit.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !raw.isEmpty else { return nil }
+ return Int(raw)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index f6a9b2a3b..d002d5ea5 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -24,6 +24,7 @@ enum ProviderImplementationRegistry {
case .minimax: MiniMaxProviderImplementation()
case .kimi: KimiProviderImplementation()
case .kiro: KiroProviderImplementation()
+ case .qwencode: QwenCodeProviderImplementation()
case .vertexai: VertexAIProviderImplementation()
case .augment: AugmentProviderImplementation()
case .jetbrains: JetBrainsProviderImplementation()
diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg b/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg
new file mode 100644
index 000000000..17f230854
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-qwencode.svg
@@ -0,0 +1,4 @@
+
diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift
index 0e06b99fc..daac4d809 100644
--- a/Sources/CodexBar/SettingsStore+Defaults.swift
+++ b/Sources/CodexBar/SettingsStore+Defaults.swift
@@ -231,6 +231,14 @@ extension SettingsStore {
}
}
+ var qwenCodeDailyRequestLimit: String {
+ get { self.defaultsState.qwenCodeDailyRequestLimit }
+ set {
+ self.defaultsState.qwenCodeDailyRequestLimit = newValue
+ self.userDefaults.set(newValue, forKey: "qwenCodeDailyRequestLimit")
+ }
+ }
+
var mergeIcons: Bool {
get { self.defaultsState.mergeIcons }
set {
diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift
index 6c69f6a7f..4e2aa60db 100644
--- a/Sources/CodexBar/SettingsStore+MenuObservation.swift
+++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift
@@ -53,6 +53,7 @@ extension SettingsStore {
_ = self.augmentCookieHeader
_ = self.ampCookieHeader
_ = self.copilotAPIToken
+ _ = self.qwenCodeDailyRequestLimit
_ = self.tokenAccountsByProvider
_ = self.debugLoadingPattern
_ = self.selectedMenuProvider
diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift
index 42514476a..8a1f08bcf 100644
--- a/Sources/CodexBar/SettingsStore.swift
+++ b/Sources/CodexBar/SettingsStore.swift
@@ -207,6 +207,7 @@ extension SettingsStore {
let openAIWebAccessEnabled = openAIWebAccessDefault ?? true
if openAIWebAccessDefault == nil { userDefaults.set(true, forKey: "openAIWebAccessEnabled") }
let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? ""
+ let qwenCodeDailyRequestLimit = userDefaults.string(forKey: "qwenCodeDailyRequestLimit") ?? "2000"
let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true
let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
@@ -237,6 +238,7 @@ extension SettingsStore {
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
openAIWebAccessEnabled: openAIWebAccessEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
+ qwenCodeDailyRequestLimit: qwenCodeDailyRequestLimit,
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
selectedMenuProviderRaw: selectedMenuProviderRaw,
diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift
index 9d8e833ba..6fdfcbc04 100644
--- a/Sources/CodexBar/SettingsStoreState.swift
+++ b/Sources/CodexBar/SettingsStoreState.swift
@@ -25,6 +25,7 @@ struct SettingsDefaultsState: Sendable {
var showOptionalCreditsAndExtraUsage: Bool
var openAIWebAccessEnabled: Bool
var jetbrainsIDEBasePath: String
+ var qwenCodeDailyRequestLimit: String
var mergeIcons: Bool
var switcherShowsIcons: Bool
var selectedMenuProviderRaw: String?
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 69491056d..fa96d45bb 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1207,6 +1207,10 @@ extension UsageStore {
let text = "Kiro debug log not yet implemented"
await MainActor.run { self.probeLogs[.kiro] = text }
return text
+ case .qwencode:
+ let text = "Qwen Code debug log not yet implemented"
+ await MainActor.run { self.probeLogs[.qwencode] = text }
+ return text
case .augment:
let text = await self.debugAugmentLog()
await MainActor.run { self.probeLogs[.augment] = text }
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index a0a88371d..752646099 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -147,7 +147,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic:
+ case .gemini, .antigravity, .copilot, .kiro, .qwencode, .vertexai, .kimik2, .synthetic:
return nil
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6aff83695..43ce36517 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -65,6 +65,7 @@ public enum ProviderDescriptorRegistry {
.minimax: MiniMaxProviderDescriptor.descriptor,
.kimi: KimiProviderDescriptor.descriptor,
.kiro: KiroProviderDescriptor.descriptor,
+ .qwencode: QwenCodeProviderDescriptor.descriptor,
.vertexai: VertexAIProviderDescriptor.descriptor,
.augment: AugmentProviderDescriptor.descriptor,
.jetbrains: JetBrainsProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index f83cb9fd1..5270153ba 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -15,6 +15,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: KimiProviderSettings? = nil,
augment: AugmentProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
+ qwencode: QwenCodeProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
@@ -31,6 +32,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: kimi,
augment: augment,
amp: amp,
+ qwencode: qwencode,
jetbrains: jetbrains)
}
@@ -157,6 +159,14 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct QwenCodeProviderSettings: Sendable {
+ public let dailyRequestLimit: Int?
+
+ public init(dailyRequestLimit: Int?) {
+ self.dailyRequestLimit = dailyRequestLimit
+ }
+ }
+
public struct AmpProviderSettings: Sendable {
public let cookieSource: ProviderCookieSource
public let manualCookieHeader: String?
@@ -180,6 +190,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let kimi: KimiProviderSettings?
public let augment: AugmentProviderSettings?
public let amp: AmpProviderSettings?
+ public let qwencode: QwenCodeProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
public var jetbrainsIDEBasePath: String? {
@@ -200,6 +211,7 @@ public struct ProviderSettingsSnapshot: Sendable {
kimi: KimiProviderSettings?,
augment: AugmentProviderSettings?,
amp: AmpProviderSettings?,
+ qwencode: QwenCodeProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
@@ -215,6 +227,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.kimi = kimi
self.augment = augment
self.amp = amp
+ self.qwencode = qwencode
self.jetbrains = jetbrains
}
}
@@ -231,6 +244,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case kimi(ProviderSettingsSnapshot.KimiProviderSettings)
case augment(ProviderSettingsSnapshot.AugmentProviderSettings)
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
+ case qwencode(ProviderSettingsSnapshot.QwenCodeProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
}
@@ -248,6 +262,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var kimi: ProviderSettingsSnapshot.KimiProviderSettings?
public var augment: ProviderSettingsSnapshot.AugmentProviderSettings?
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
+ public var qwencode: ProviderSettingsSnapshot.QwenCodeProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
@@ -268,6 +283,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .kimi(value): self.kimi = value
case let .augment(value): self.augment = value
case let .amp(value): self.amp = value
+ case let .qwencode(value): self.qwencode = value
case let .jetbrains(value): self.jetbrains = value
}
}
@@ -287,6 +303,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
kimi: self.kimi,
augment: self.augment,
amp: self.amp,
+ qwencode: self.qwencode,
jetbrains: self.jetbrains)
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
index e79d0487d..51bf5e479 100644
--- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
+++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
@@ -28,6 +28,19 @@ public enum ProviderVersionDetector {
return nil
}
+ public static func qwenVersion() -> String? {
+ guard let path = TTYCommandRunner.which("qwen") else { return nil }
+ let candidates = [
+ ["--version"],
+ ["-v"],
+ ["version"],
+ ]
+ for args in candidates {
+ if let version = Self.run(path: path, args: args) { return version }
+ }
+ return nil
+ }
+
private static func run(path: String, args: [String]) -> String? {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: path)
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index a267fb953..e284652e1 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -15,6 +15,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case minimax
case kimi
case kiro
+ case qwencode
case vertexai
case augment
case jetbrains
@@ -39,6 +40,7 @@ public enum IconStyle: Sendable, CaseIterable {
case kimi
case kimik2
case kiro
+ case qwencode
case vertexai
case augment
case jetbrains
diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift
new file mode 100644
index 000000000..2b9a80c93
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeProviderDescriptor.swift
@@ -0,0 +1,80 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum QwenCodeProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .qwencode,
+ metadata: ProviderMetadata(
+ id: .qwencode,
+ displayName: "Qwen Code",
+ sessionLabel: "Requests",
+ weeklyLabel: "Tokens",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Qwen Code usage",
+ cliName: "qwen",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: "https://chat.qwen.ai",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .qwencode,
+ iconResourceName: "ProviderIcon-qwencode",
+ color: ProviderColor(red: 32 / 255, green: 140 / 255, blue: 220 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Qwen Code cost summary is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .cli],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [QwenCodeLocalFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "qwen",
+ aliases: ["qwen-code"],
+ versionDetector: { _ in ProviderVersionDetector.qwenVersion() }))
+ }
+}
+
+struct QwenCodeLocalFetchStrategy: ProviderFetchStrategy {
+ let id: String = "qwencode.local"
+ let kind: ProviderFetchKind = .localProbe
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ let baseDirectory = QwenCodeUsageProbe.resolveBaseDirectory(env: context.env)
+ return QwenCodeUsageProbe.projectsDirectoryExists(baseDirectory: baseDirectory)
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let baseDirectory = QwenCodeUsageProbe.resolveBaseDirectory(env: context.env)
+ let requestLimit = Self.resolveRequestLimit(settings: context.settings, env: context.env)
+ let probe = QwenCodeUsageProbe(requestLimit: requestLimit, baseDirectory: baseDirectory)
+ let snapshot = try probe.fetch()
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(requestLimit: requestLimit),
+ sourceLabel: "local")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveRequestLimit(settings: ProviderSettingsSnapshot?, env: [String: String]) -> Int {
+ if let override = env["CODEXBAR_QWENCODE_DAILY_REQUEST_LIMIT"],
+ let limit = Int(override.trimmingCharacters(in: .whitespacesAndNewlines)),
+ limit > 0
+ {
+ return limit
+ }
+
+ if let limit = settings?.qwencode?.dailyRequestLimit, limit > 0 {
+ return limit
+ }
+
+ return QwenCodeUsageProbe.defaultDailyRequestLimit
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift
new file mode 100644
index 000000000..6f706a93b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageProbe.swift
@@ -0,0 +1,209 @@
+import Foundation
+
+public enum QwenCodeUsageProbeError: LocalizedError, Sendable, Equatable {
+ case projectsDirectoryMissing(String)
+ case noChatLogsFound
+ case invalidRequestLimit(Int)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .projectsDirectoryMissing(path):
+ "Qwen Code projects directory not found at \(path). Run qwen at least once to create logs."
+ case .noChatLogsFound:
+ "No Qwen Code chat logs found. Start a session and try again."
+ case let .invalidRequestLimit(limit):
+ "Qwen Code daily request limit is invalid (\(limit)). Set a positive value in Settings."
+ }
+ }
+}
+
+public struct QwenCodeUsageProbe: Sendable {
+ public static let defaultDailyRequestLimit = 2000
+
+ private static let qwenDirName = ".qwen"
+ private static let projectsDirName = "projects"
+ private static let chatsDirName = "chats"
+ private static let oauthCredsFile = "oauth_creds.json"
+
+ private let requestLimit: Int
+ private let baseDirectory: URL
+ private let now: Date
+
+ public init(requestLimit: Int, baseDirectory: URL, now: Date = Date()) {
+ self.requestLimit = requestLimit
+ self.baseDirectory = baseDirectory
+ self.now = now
+ }
+
+ public static func resolveBaseDirectory(env: [String: String]) -> URL {
+ if let override = env["CODEXBAR_QWEN_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !override.isEmpty
+ {
+ return URL(fileURLWithPath: (override as NSString).expandingTildeInPath)
+ }
+ if let override = env["QWEN_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !override.isEmpty
+ {
+ return URL(fileURLWithPath: (override as NSString).expandingTildeInPath)
+ }
+ return FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(self.qwenDirName)
+ }
+
+ public static func projectsDirectoryExists(baseDirectory: URL) -> Bool {
+ let projectsURL = baseDirectory.appendingPathComponent(self.projectsDirName)
+ return FileManager.default.fileExists(atPath: projectsURL.path)
+ }
+
+ public func fetch() throws -> QwenCodeUsageSnapshot {
+ guard self.requestLimit > 0 else {
+ throw QwenCodeUsageProbeError.invalidRequestLimit(self.requestLimit)
+ }
+
+ let projectsURL = self.baseDirectory.appendingPathComponent(Self.projectsDirName)
+ guard FileManager.default.fileExists(atPath: projectsURL.path) else {
+ throw QwenCodeUsageProbeError.projectsDirectoryMissing(projectsURL.path)
+ }
+
+ let (windowStart, windowEnd) = self.dailyWindow(now: self.now)
+ var totalRequests = 0
+ var totalTokens = 0
+ var sawAnyLogs = false
+
+ let projectDirs = (try? FileManager.default.contentsOfDirectory(
+ at: projectsURL,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: [.skipsHiddenFiles])) ?? []
+
+ for projectURL in projectDirs {
+ guard projectURL.hasDirectoryPath else { continue }
+ let chatsURL = projectURL.appendingPathComponent(Self.chatsDirName)
+ guard FileManager.default.fileExists(atPath: chatsURL.path) else { continue }
+
+ let chatFiles = (try? FileManager.default.contentsOfDirectory(
+ at: chatsURL,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles])) ?? []
+
+ for fileURL in chatFiles where fileURL.pathExtension == "jsonl" {
+ sawAnyLogs = true
+ let data = (try? Data(contentsOf: fileURL)) ?? Data()
+ guard !data.isEmpty else { continue }
+ guard let text = String(bytes: data, encoding: .utf8) else { continue }
+ for line in text.split(whereSeparator: \.isNewline) {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard let record = Self.decodeRecord(from: trimmed) else { continue }
+ guard let timestamp = record.timestamp,
+ let date = Self.parseTimestamp(timestamp)
+ else {
+ continue
+ }
+ guard date >= windowStart, date < windowEnd else { continue }
+ guard record.type == "assistant" else { continue }
+ guard let usage = record.usageMetadata else { continue }
+ totalRequests += 1
+ totalTokens += usage.totalTokens
+ }
+ }
+ }
+
+ guard sawAnyLogs else {
+ throw QwenCodeUsageProbeError.noChatLogsFound
+ }
+
+ let (email, loginMethod) = self.loadIdentity()
+
+ return QwenCodeUsageSnapshot(
+ requests: totalRequests,
+ totalTokens: totalTokens,
+ windowStart: windowStart,
+ windowEnd: windowEnd,
+ updatedAt: self.now,
+ accountEmail: email,
+ loginMethod: loginMethod)
+ }
+
+ private func dailyWindow(now: Date) -> (Date, Date) {
+ let calendar = Calendar.current
+ let start = calendar.startOfDay(for: now)
+ let end = calendar.date(byAdding: .day, value: 1, to: start) ?? now
+ return (start, end)
+ }
+
+ private func loadIdentity() -> (String?, String?) {
+ let credsURL = self.baseDirectory.appendingPathComponent(Self.oauthCredsFile)
+ guard let data = try? Data(contentsOf: credsURL),
+ let creds = try? JSONDecoder().decode(QwenCodeOAuthCredentials.self, from: data)
+ else {
+ return (nil, nil)
+ }
+
+ let email: String? = creds.idToken.flatMap { token in
+ guard let payload = UsageFetcher.parseJWT(token) else { return nil }
+ if let raw = payload["email"] as? String {
+ return raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return nil
+ }
+ return (email, "Qwen OAuth")
+ }
+
+ private static func decodeRecord(from line: String) -> QwenCodeChatRecord? {
+ guard let data = line.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(QwenCodeChatRecord.self, from: data)
+ }
+
+ private static func parseTimestamp(_ raw: String) -> Date? {
+ let formatterWithFractional = ISO8601DateFormatter()
+ formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatterWithFractional.date(from: raw) {
+ return date
+ }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: raw)
+ }
+}
+
+private struct QwenCodeOAuthCredentials: Decodable {
+ let idToken: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case idToken = "id_token"
+ }
+}
+
+private struct QwenCodeChatRecord: Decodable {
+ let type: String?
+ let timestamp: String?
+ let usageMetadata: QwenCodeUsageMetadata?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case timestamp
+ case usageMetadata
+ }
+}
+
+private struct QwenCodeUsageMetadata: Decodable {
+ let promptTokenCount: Int?
+ let candidatesTokenCount: Int?
+ let totalTokenCount: Int?
+ let cachedContentTokenCount: Int?
+ let thoughtsTokenCount: Int?
+
+ var totalTokens: Int {
+ if let totalTokenCount {
+ return totalTokenCount
+ }
+ return [
+ self.promptTokenCount,
+ self.candidatesTokenCount,
+ self.cachedContentTokenCount,
+ self.thoughtsTokenCount,
+ ]
+ .compactMap(\.self)
+ .reduce(0, +)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift
new file mode 100644
index 000000000..e634de3a5
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/QwenCode/QwenCodeUsageSnapshot.swift
@@ -0,0 +1,56 @@
+import Foundation
+
+public struct QwenCodeUsageSnapshot: Sendable {
+ public let requests: Int
+ public let totalTokens: Int
+ public let windowStart: Date
+ public let windowEnd: Date
+ public let updatedAt: Date
+ public let accountEmail: String?
+ public let loginMethod: String?
+
+ public init(
+ requests: Int,
+ totalTokens: Int,
+ windowStart: Date,
+ windowEnd: Date,
+ updatedAt: Date,
+ accountEmail: String?,
+ loginMethod: String?)
+ {
+ self.requests = requests
+ self.totalTokens = totalTokens
+ self.windowStart = windowStart
+ self.windowEnd = windowEnd
+ self.updatedAt = updatedAt
+ self.accountEmail = accountEmail
+ self.loginMethod = loginMethod
+ }
+
+ public func toUsageSnapshot(requestLimit: Int) -> UsageSnapshot {
+ let usedPercent: Double = if requestLimit > 0 {
+ min(100, max(0, (Double(self.requests) / Double(requestLimit)) * 100))
+ } else {
+ 0
+ }
+ let resetDescription = UsageFormatter.resetDescription(from: self.windowEnd)
+ let primary = RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 24 * 60,
+ resetsAt: self.windowEnd,
+ resetDescription: resetDescription)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .qwencode,
+ accountEmail: self.accountEmail,
+ accountOrganization: nil,
+ loginMethod: self.loginMethod)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index c50b106c6..53bb36600 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -87,6 +87,8 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .kiro:
return CostUsageDailyReport(data: [], summary: nil)
+ case .qwencode:
+ return CostUsageDailyReport(data: [], summary: nil)
case .kimi:
return CostUsageDailyReport(data: [], summary: nil)
case .kimik2:
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 1634611ee..63b9f48c5 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -53,6 +53,7 @@ enum ProviderChoice: String, AppEnum {
case .minimax: self = .minimax
case .vertexai: return nil // Vertex AI not yet supported in widgets
case .kiro: return nil // Kiro not yet supported in widgets
+ case .qwencode: return nil // Qwen Code not yet supported in widgets
case .augment: return nil // Augment not yet supported in widgets
case .jetbrains: return nil // JetBrains not yet supported in widgets
case .kimi: return nil // Kimi not yet supported in widgets
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index ed39b4506..53e4d5259 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -269,6 +269,7 @@ private struct ProviderSwitchChip: View {
case .minimax: "MiniMax"
case .vertexai: "Vertex"
case .kiro: "Kiro"
+ case .qwencode: "Qwen"
case .augment: "Augment"
case .jetbrains: "JetBrains"
case .kimi: "Kimi"
@@ -593,6 +594,8 @@ enum WidgetColors {
Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue
case .kiro:
Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange
+ case .qwencode:
+ Color(red: 32 / 255, green: 140 / 255, blue: 220 / 255)
case .augment:
Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255) // Augment purple
case .jetbrains:
diff --git a/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift b/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift
new file mode 100644
index 000000000..82b07743e
--- /dev/null
+++ b/Tests/CodexBarTests/QwenCodeUsageProbeTests.swift
@@ -0,0 +1,76 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct QwenCodeUsageProbeTests {
+ @Test
+ func aggregatesDailyUsageFromLogs() throws {
+ let fm = FileManager.default
+ let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? fm.removeItem(at: baseDir) }
+
+ let chatsDir = baseDir
+ .appendingPathComponent("projects")
+ .appendingPathComponent("sample-project")
+ .appendingPathComponent("chats")
+ try fm.createDirectory(at: chatsDir, withIntermediateDirectories: true)
+
+ let calendar = Calendar.current
+ let now = calendar.date(from: DateComponents(year: 2026, month: 1, day: 15, hour: 12, minute: 0))!
+ let yesterday = calendar.date(byAdding: .day, value: -1, to: now)!
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let todayStamp = formatter.string(from: now)
+ let yesterdayStamp = formatter.string(from: yesterday)
+
+ let todayLine = "{\"type\":\"assistant\",\"timestamp\":\"\(todayStamp)\",\"usageMetadata\":"
+ + "{\"promptTokenCount\":10,\"candidatesTokenCount\":20,\"totalTokenCount\":30}}"
+ let yesterdayLine = "{\"type\":\"assistant\",\"timestamp\":\"\(yesterdayStamp)\",\"usageMetadata\":"
+ + "{\"promptTokenCount\":5,\"candidatesTokenCount\":5,\"totalTokenCount\":10}}"
+ let lines = [
+ todayLine,
+ yesterdayLine,
+ "{\"type\":\"user\",\"timestamp\":\"\(todayStamp)\"}",
+ ]
+ let logURL = chatsDir.appendingPathComponent("session.jsonl")
+ try lines.joined(separator: "\n").write(to: logURL, atomically: true, encoding: .utf8)
+
+ let probe = QwenCodeUsageProbe(requestLimit: 100, baseDirectory: baseDir, now: now)
+ let snapshot = try probe.fetch()
+
+ #expect(snapshot.requests == 1)
+ #expect(snapshot.totalTokens == 30)
+
+ let usage = snapshot.toUsageSnapshot(requestLimit: 100)
+ #expect(usage.primary?.usedPercent == 1)
+ }
+
+ @Test
+ func throwsWhenProjectsDirectoryMissing() {
+ let fm = FileManager.default
+ let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? fm.removeItem(at: baseDir) }
+ let now = Date()
+
+ let probe = QwenCodeUsageProbe(requestLimit: 100, baseDirectory: baseDir, now: now)
+
+ #expect(throws: QwenCodeUsageProbeError.self) {
+ try probe.fetch()
+ }
+ }
+
+ @Test
+ func throwsWhenRequestLimitInvalid() {
+ let fm = FileManager.default
+ let baseDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? fm.removeItem(at: baseDir) }
+
+ let probe = QwenCodeUsageProbe(requestLimit: 0, baseDirectory: baseDir)
+
+ #expect(throws: QwenCodeUsageProbeError.self) {
+ try probe.fetch()
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 108d31bd9..afd1d3873 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -358,6 +358,7 @@ struct SettingsStoreTests {
.minimax,
.kimi,
.kiro,
+ .qwencode,
.vertexai,
.augment,
.jetbrains,
diff --git a/docs/providers.md b/docs/providers.md
index d25dfb887..94fd91352 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Vertex AI, Augment, Amp, JetBrains AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kimi K2, Kiro, Qwen Code, Vertex AI, Augment, Amp, JetBrains AI)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -31,6 +31,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Copilot | API token (device flow/env) → copilot_internal API (`api`). |
| Kimi K2 | API key (Keychain/env) → credit endpoint (`api`). |
| Kiro | CLI command via `kiro-cli chat --no-interactive "/usage"` (`cli`). |
+| Qwen Code | Local JSONL chat logs (`local`). |
| Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). |
| JetBrains AI | Local XML quota file (`local`). |
| Amp | Web settings page via browser cookies (`web`). |
@@ -121,6 +122,13 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: AWS Health Dashboard (manual link, no auto-polling).
- Details: `docs/kiro.md`.
+## Qwen Code
+- Local JSONL logs under `~/.qwen/projects//chats/*.jsonl`.
+- Counts assistant records with `usageMetadata` in the local-day window.
+- Daily request limit defaults to 2,000; override in Settings → Providers → Qwen Code.
+- Status: none yet.
+- Details: `docs/qwencode.md`.
+
## Vertex AI
- OAuth credentials from `gcloud auth application-default login` (ADC).
- Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`.
diff --git a/docs/qwencode.md b/docs/qwencode.md
new file mode 100644
index 000000000..244d04646
--- /dev/null
+++ b/docs/qwencode.md
@@ -0,0 +1,25 @@
+---
+summary: "Qwen Code provider: local session log parsing and daily request usage."
+read_when:
+ - Debugging Qwen Code usage
+ - Updating Qwen Code provider behavior
+---
+
+# Qwen Code
+
+## Data source
+- Local JSONL logs written by Qwen Code:
+ - `~/.qwen/projects//chats/*.jsonl`
+- Each JSONL record includes `timestamp`, `type`, and (for assistant records) `usageMetadata`.
+
+## Usage model
+- Counts assistant records with `usageMetadata` within the **local-day** window.
+- Daily request limit defaults to **2,000** (Qwen OAuth free tier).
+- Override in Settings → Providers → Qwen Code ("Daily request limit").
+- Optional environment override: `CODEXBAR_QWENCODE_DAILY_REQUEST_LIMIT`.
+
+## Identity
+- If `~/.qwen/oauth_creds.json` exists, Qwen OAuth credentials are read and the email is derived from `id_token` when possible.
+
+## Notes
+- No public usage/quota API exists yet; when available, add an API strategy and prefer it over local logs.