+
+ )
+}
diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx
index 841ff8e90..9bf88d794 100644
--- a/src/components/token-usage/token-usage-page.tsx
+++ b/src/components/token-usage/token-usage-page.tsx
@@ -42,6 +42,7 @@ import {
import { Progress } from "@/components/ui/progress"
import { ScrollArea } from "@/components/ui/scroll-area"
import { WorkbenchPageTitle } from "@/components/workbench/workbench-page-title"
+import { SubscriptionQuotaPanel } from "@/components/token-usage/subscription-quota-panel"
import { FolderAliasLabel } from "@/components/conversations/folder-alias-label"
import { formatFolderLabelWithAlias } from "@/lib/folder-display"
import {
@@ -806,6 +807,11 @@ export function TokenUsagePage() {
as segments, the long tail folded into one pill), the dimension
filters, and a single overflow menu carrying every data action.
Absent entirely on the first-run empty state — see `showToolbar`. */}
+
+
+
+
+
{showToolbar && (
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index f436b8c6d..c4a346fda 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "استهلاك الرموز",
+ "quotaTitle": "المتبقي من الاشتراك",
+ "quotaHint": "تعد Codeg الرموز في هذه الصفحة. يظهر رصيد الخطة فقط إذا نشره CLI المزوّد. استخدام ACP هو إشغال السياق وليس رصيد الاشتراك.",
+ "quotaRemaining": "متبقٍ {remaining} من {limit}",
+ "quotaProviderLink": "صفحة استخدام المزوّد",
"rangeLabel": "النطاق الزمني",
"range7d": "٧ أيام",
"range30d": "٣٠ يومًا",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 6d0a5bec3..4cda8d790 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Token-Verbrauch",
+ "quotaTitle": "Verbleibendes Abo",
+ "quotaHint": "Codeg zählt Tokens auf dieser Seite. Verbleibendes Plan-Kontingent nur wenn die CLI es veröffentlicht. ACP-Nutzung ist Kontextbelegung, kein Abo-Rest.",
+ "quotaRemaining": "{remaining} von {limit} übrig",
+ "quotaProviderLink": "Anbieter-Nutzungsseite",
"rangeLabel": "Zeitraum",
"range7d": "7 Tage",
"range30d": "30 Tage",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index b7076662e..ba62c0293 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Token Usage",
+ "quotaTitle": "Subscription remaining",
+ "quotaHint": "Codeg meters tokens on this page. Remaining plan quota is shown only when a provider CLI publishes it. ACP usage is context occupancy, not subscription remaining.",
+ "quotaRemaining": "{remaining} of {limit} remaining",
+ "quotaProviderLink": "Provider usage page",
"rangeLabel": "Time range",
"range7d": "7 days",
"range30d": "30 days",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 21ca17fa8..20b0b704f 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Uso de tokens",
+ "quotaTitle": "Suscripción restante",
+ "quotaHint": "Codeg mide tokens en esta página. El cupo restante del plan solo se muestra si el CLI del proveedor lo publica. El uso ACP es ocupación de contexto, no resto de suscripción.",
+ "quotaRemaining": "{remaining} de {limit} restantes",
+ "quotaProviderLink": "Página de uso del proveedor",
"rangeLabel": "Periodo",
"range7d": "7 días",
"range30d": "30 días",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 692b556a8..81ef0e96e 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Consommation de tokens",
+ "quotaTitle": "Reste d’abonnement",
+ "quotaHint": "Codeg compte les tokens sur cette page. Le quota restant n’apparaît que si le CLI du fournisseur le publie. L’usage ACP est l’occupation du contexte, pas le reste d’abonnement.",
+ "quotaRemaining": "{remaining} sur {limit} restants",
+ "quotaProviderLink": "Page d’usage du fournisseur",
"rangeLabel": "Période",
"range7d": "7 jours",
"range30d": "30 jours",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 24b77626a..c73658221 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "トークン使用量",
+ "quotaTitle": "サブスク残量",
+ "quotaHint": "このページは Codeg が計測したトークンです。プラン残量は公式 CLI が公開する場合のみ表示します。ACP の usage はコンテキスト占有であり、契約残量ではありません。",
+ "quotaRemaining": "残り {remaining} / {limit}",
+ "quotaProviderLink": "プロバイダーの使用量ページ",
"rangeLabel": "期間",
"range7d": "7日間",
"range30d": "30日間",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index e76fe0b77..a42066960 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "토큰 사용량",
+ "quotaTitle": "구독 잔여량",
+ "quotaHint": "이 페이지는 Codeg가 측정한 토큰입니다. 플랜 잔여량은 제공자 CLI가 공개할 때만 표시됩니다. ACP usage는 컨텍스트 점유이며 구독 잔여량이 아닙니다.",
+ "quotaRemaining": "{limit} 중 {remaining} 남음",
+ "quotaProviderLink": "제공자 사용량 페이지",
"rangeLabel": "기간",
"range7d": "7일",
"range30d": "30일",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 6ece222b0..ea9c162a2 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Uso de tokens",
+ "quotaTitle": "Assinatura restante",
+ "quotaHint": "O Codeg mede tokens nesta página. A cota restante do plano só aparece se o CLI do provedor a publicar. O uso ACP é ocupação de contexto, não resto de assinatura.",
+ "quotaRemaining": "{remaining} de {limit} restantes",
+ "quotaProviderLink": "Página de uso do provedor",
"rangeLabel": "Período",
"range7d": "7 dias",
"range30d": "30 dias",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index e62f6d74c..c3f547ae9 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Token 用量",
+ "quotaTitle": "订阅余量",
+ "quotaHint": "本页显示 Codeg 计量的 token。套餐余量仅在提供商 CLI 公开时显示。ACP usage 是上下文占用,不是订阅余量。",
+ "quotaRemaining": "剩余 {remaining} / {limit}",
+ "quotaProviderLink": "提供商用量页",
"rangeLabel": "时间范围",
"range7d": "近 7 天",
"range30d": "近 30 天",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index e6283cf66..82703518f 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -4925,6 +4925,10 @@
},
"TokenUsage": {
"title": "Token 用量",
+ "quotaTitle": "訂閱餘量",
+ "quotaHint": "本頁顯示 Codeg 計量的 token。方案餘量僅在供應商 CLI 公開時顯示。ACP usage 是上下文占用,不是訂閱餘量。",
+ "quotaRemaining": "剩餘 {remaining} / {limit}",
+ "quotaProviderLink": "供應商用量頁",
"rangeLabel": "時間範圍",
"range7d": "近 7 天",
"range30d": "近 30 天",
diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts
new file mode 100644
index 000000000..3448cd597
--- /dev/null
+++ b/src/lib/subscription-quota.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest"
+import {
+ emitsRemainingSubscription,
+ familyQuota,
+ inventory,
+ remainingFromOfficialPayload,
+} from "./subscription-quota"
+
+describe("subscription quota inventory", () => {
+ it("does not invent remaining-subscription numbers when no official payload exists", () => {
+ for (const row of inventory()) {
+ expect(emitsRemainingSubscription(row)).toBe(false)
+ expect(row.kind).toBe("unavailable")
+ if (row.kind === "unavailable") {
+ expect(row.providerUsageUrl.startsWith("https://")).toBe(true)
+ }
+ }
+ })
+
+ it("treats ACP usage_update as context occupancy, not plan remaining", () => {
+ const row = familyQuota("claude", undefined, { used: 1200, size: 8000 })
+ expect(row.kind).toBe("acp-context")
+ expect(emitsRemainingSubscription(row)).toBe(false)
+ if (row.kind === "acp-context") {
+ expect(row.used).toBe(1200)
+ expect(row.size).toBe(8000)
+ }
+ })
+
+ it("reads remaining only from a recorded official remaining-subscription envelope", () => {
+ const payload = {
+ codegQuotaKind: "remaining-subscription",
+ family: "codex",
+ remaining: 42,
+ limit: 100,
+ source: "recorded-official-fixture",
+ }
+ const parsed = remainingFromOfficialPayload("codex", payload)
+ expect(parsed).toEqual({
+ remaining: 42,
+ limit: 100,
+ source: "recorded-official-fixture",
+ })
+ const row = familyQuota("codex", payload)
+ expect(row.kind).toBe("remaining-subscription")
+ if (row.kind === "remaining-subscription") {
+ expect(row.remaining).toBe(42)
+ }
+ })
+
+ it("rejects a payload that is not the official envelope", () => {
+ expect(
+ remainingFromOfficialPayload("claude", { remaining: 1, limit: 2 })
+ ).toBeNull()
+ expect(
+ remainingFromOfficialPayload("claude", {
+ codegQuotaKind: "remaining-subscription",
+ family: "codex",
+ remaining: 1,
+ limit: 2,
+ source: "wrong-family",
+ })
+ ).toBeNull()
+ })
+})
diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts
new file mode 100644
index 000000000..a10008982
--- /dev/null
+++ b/src/lib/subscription-quota.ts
@@ -0,0 +1,122 @@
+/**
+ * Remaining-subscription inventory.
+ *
+ * ACP `usage_update` is context occupancy `{used, size}`, not plan remaining.
+ * Official CLIs (claude /usage, chatgpt, grok, gemini, opencode) do not
+ * publish a stable remaining-quota JSON that Codeg can read. This module is
+ * the single gate: a remaining-subscription number is emitted only when a
+ * recorded official payload actually contains one.
+ */
+
+export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode"
+
+export type QuotaKind = "remaining-subscription" | "acp-context" | "unavailable"
+
+export type FamilyQuota =
+ | {
+ family: IsolatableFamily
+ kind: "remaining-subscription"
+ remaining: number
+ limit: number
+ source: string
+ }
+ | {
+ family: IsolatableFamily
+ kind: "acp-context"
+ used: number
+ size: number
+ }
+ | {
+ family: IsolatableFamily
+ kind: "unavailable"
+ providerUsageUrl: string
+ }
+
+export const PROVIDER_USAGE_URLS: Record = {
+ claude: "https://claude.ai/settings/usage",
+ codex: "https://chatgpt.com/#settings",
+ grok: "https://accounts.x.ai/",
+ gemini: "https://aistudio.google.com/",
+ opencode: "https://opencode.ai/",
+}
+
+const FAMILIES: IsolatableFamily[] = [
+ "claude",
+ "codex",
+ "grok",
+ "gemini",
+ "opencode",
+]
+
+/**
+ * Read remaining subscription from a recorded official payload.
+ * Production Codeg never invents this object. Return null unless the
+ * payload is the documented official shape for that family.
+ */
+export function remainingFromOfficialPayload(
+ family: IsolatableFamily,
+ payload: unknown
+): { remaining: number; limit: number; source: string } | null {
+ if (!payload || typeof payload !== "object") return null
+ const rec = payload as Record
+ // No family currently publishes this object. Accept only an explicit
+ // official envelope so a future CLI JSON can land without inventing
+ // numbers today.
+ if (rec.codegQuotaKind !== "remaining-subscription") return null
+ if (rec.family !== family) return null
+ if (typeof rec.remaining !== "number" || typeof rec.limit !== "number") {
+ return null
+ }
+ if (!Number.isFinite(rec.remaining) || !Number.isFinite(rec.limit)) {
+ return null
+ }
+ if (typeof rec.source !== "string" || !rec.source.trim()) return null
+ return {
+ remaining: rec.remaining,
+ limit: rec.limit,
+ source: rec.source.trim(),
+ }
+}
+
+export function acpContextFromPayload(
+ payload: unknown
+): { used: number; size: number } | null {
+ if (!payload || typeof payload !== "object") return null
+ const rec = payload as Record
+ if (typeof rec.used !== "number" || typeof rec.size !== "number") return null
+ if (!Number.isFinite(rec.used) || !Number.isFinite(rec.size)) return null
+ return { used: rec.used, size: rec.size }
+}
+
+export function familyQuota(
+ family: IsolatableFamily,
+ officialPayload?: unknown,
+ acpUsage?: unknown
+): FamilyQuota {
+ const remaining = remainingFromOfficialPayload(family, officialPayload)
+ if (remaining) {
+ return { family, kind: "remaining-subscription", ...remaining }
+ }
+ const context = acpContextFromPayload(acpUsage)
+ if (context) {
+ return { family, kind: "acp-context", ...context }
+ }
+ return {
+ family,
+ kind: "unavailable",
+ providerUsageUrl: PROVIDER_USAGE_URLS[family],
+ }
+}
+
+export function inventory(
+ officialByFamily: Partial> = {},
+ acpByFamily: Partial> = {}
+): FamilyQuota[] {
+ return FAMILIES.map((family) =>
+ familyQuota(family, officialByFamily[family], acpByFamily[family])
+ )
+}
+
+export function emitsRemainingSubscription(row: FamilyQuota): boolean {
+ return row.kind === "remaining-subscription"
+}
From 0eb301225a829bc58f8876a0b3ae4ca1dd4cd505 Mon Sep 17 00:00:00 2001
From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Date: Sat, 15 Aug 2026 10:52:51 -0700
Subject: [PATCH 02/11] Parse official Codex and Claude remaining-quota
payloads
Codex remaining comes from documented app-server account/rateLimits/read.
Claude remaining comes from the /usage HUD five_hour utilization payload.
Other families still link to the provider page instead of inventing a number.
---
src/lib/subscription-quota.test.ts | 40 +++++++-------
src/lib/subscription-quota.ts | 89 +++++++++++++++++++++---------
2 files changed, 85 insertions(+), 44 deletions(-)
diff --git a/src/lib/subscription-quota.test.ts b/src/lib/subscription-quota.test.ts
index 3448cd597..a3a02677f 100644
--- a/src/lib/subscription-quota.test.ts
+++ b/src/lib/subscription-quota.test.ts
@@ -27,38 +27,40 @@ describe("subscription quota inventory", () => {
}
})
- it("reads remaining only from a recorded official remaining-subscription envelope", () => {
+ it("reads Codex remaining from documented account/rateLimits/read", () => {
const payload = {
- codegQuotaKind: "remaining-subscription",
- family: "codex",
- remaining: 42,
- limit: 100,
- source: "recorded-official-fixture",
+ rateLimits: {
+ primary: { usedPercent: 42, resetsAt: 1_775_000_000 },
+ secondary: { usedPercent: 10, resetsAt: 1_775_500_000 },
+ },
}
const parsed = remainingFromOfficialPayload("codex", payload)
expect(parsed).toEqual({
- remaining: 42,
+ remaining: 58,
limit: 100,
- source: "recorded-official-fixture",
+ source: "codex account/rateLimits/read",
})
- const row = familyQuota("codex", payload)
- expect(row.kind).toBe("remaining-subscription")
- if (row.kind === "remaining-subscription") {
- expect(row.remaining).toBe(42)
+ expect(familyQuota("codex", payload).kind).toBe("remaining-subscription")
+ })
+
+ it("reads Claude remaining from the /usage HUD payload", () => {
+ const payload = {
+ five_hour: { utilization: 0.42, resets_at: "2026-02-28T17:00:00Z" },
+ seven_day: { utilization: 0.61, resets_at: "2026-03-07T08:00:00Z" },
}
+ const parsed = remainingFromOfficialPayload("claude", payload)
+ expect(parsed?.source).toBe("claude /usage")
+ expect(parsed?.remaining).toBeCloseTo(58)
+ expect(familyQuota("claude", payload).kind).toBe("remaining-subscription")
})
- it("rejects a payload that is not the official envelope", () => {
+ it("rejects a payload that is not the official family shape", () => {
expect(
remainingFromOfficialPayload("claude", { remaining: 1, limit: 2 })
).toBeNull()
expect(
- remainingFromOfficialPayload("claude", {
- codegQuotaKind: "remaining-subscription",
- family: "codex",
- remaining: 1,
- limit: 2,
- source: "wrong-family",
+ remainingFromOfficialPayload("grok", {
+ rateLimits: { primary: { usedPercent: 10 } },
})
).toBeNull()
})
diff --git a/src/lib/subscription-quota.ts b/src/lib/subscription-quota.ts
index a10008982..b7288050d 100644
--- a/src/lib/subscription-quota.ts
+++ b/src/lib/subscription-quota.ts
@@ -2,10 +2,11 @@
* Remaining-subscription inventory.
*
* ACP `usage_update` is context occupancy `{used, size}`, not plan remaining.
- * Official CLIs (claude /usage, chatgpt, grok, gemini, opencode) do not
- * publish a stable remaining-quota JSON that Codeg can read. This module is
- * the single gate: a remaining-subscription number is emitted only when a
- * recorded official payload actually contains one.
+ * Official remaining-quota sources we parse:
+ * Codex: documented app-server `account/rateLimits/read`
+ * (primary = 5-hour window, secondary = weekly).
+ * Claude: the `/usage` HUD payload (`five_hour` / `seven_day` utilization).
+ * Gemini / Grok / OpenCode still have no machine-readable remaining quota.
*/
export type IsolatableFamily = "claude" | "codex" | "grok" | "gemini" | "opencode"
@@ -48,34 +49,72 @@ const FAMILIES: IsolatableFamily[] = [
"opencode",
]
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function percentRemaining(usedPercent: unknown): number | null {
+ if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) {
+ return null
+ }
+ return Math.max(0, Math.min(100, 100 - usedPercent))
+}
+
+function utilizationRemaining(utilization: unknown): number | null {
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) {
+ return null
+ }
+ return Math.max(0, Math.min(100, (1 - utilization) * 100))
+}
+
+/** Documented Codex app-server `account/rateLimits/read` result. */
+export function remainingFromCodexAppServer(
+ payload: unknown
+): { remaining: number; limit: number; source: string } | null {
+ const rec = asRecord(payload)
+ if (!rec) return null
+ const result = asRecord(rec.result) ?? rec
+ const limits = asRecord(result.rateLimits)
+ if (!limits) return null
+ const primary = asRecord(limits.primary)
+ const remaining = percentRemaining(primary?.usedPercent)
+ if (remaining == null) return null
+ return {
+ remaining,
+ limit: 100,
+ source: "codex account/rateLimits/read",
+ }
+}
+
+/** Claude `/usage` HUD payload (`five_hour.utilization`). */
+export function remainingFromClaudeUsageHud(
+ payload: unknown
+): { remaining: number; limit: number; source: string } | null {
+ const rec = asRecord(payload)
+ if (!rec) return null
+ const five = asRecord(rec.five_hour)
+ const remaining = utilizationRemaining(five?.utilization)
+ if (remaining == null) return null
+ return {
+ remaining,
+ limit: 100,
+ source: "claude /usage",
+ }
+}
+
/**
* Read remaining subscription from a recorded official payload.
- * Production Codeg never invents this object. Return null unless the
- * payload is the documented official shape for that family.
+ * Production Codeg never invents this object.
*/
export function remainingFromOfficialPayload(
family: IsolatableFamily,
payload: unknown
): { remaining: number; limit: number; source: string } | null {
- if (!payload || typeof payload !== "object") return null
- const rec = payload as Record
- // No family currently publishes this object. Accept only an explicit
- // official envelope so a future CLI JSON can land without inventing
- // numbers today.
- if (rec.codegQuotaKind !== "remaining-subscription") return null
- if (rec.family !== family) return null
- if (typeof rec.remaining !== "number" || typeof rec.limit !== "number") {
- return null
- }
- if (!Number.isFinite(rec.remaining) || !Number.isFinite(rec.limit)) {
- return null
- }
- if (typeof rec.source !== "string" || !rec.source.trim()) return null
- return {
- remaining: rec.remaining,
- limit: rec.limit,
- source: rec.source.trim(),
- }
+ if (family === "codex") return remainingFromCodexAppServer(payload)
+ if (family === "claude") return remainingFromClaudeUsageHud(payload)
+ return null
}
export function acpContextFromPayload(
From c0fae860035c60eda0f0888e92d235c9304fa050 Mon Sep 17 00:00:00 2001
From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Date: Sat, 15 Aug 2026 11:41:16 -0700
Subject: [PATCH 03/11] Read live Codex remaining quota from app-server
subscription-quota now calls official `codex app-server --stdio`
`account/rateLimits/read` and parses the live envelope (primary
usedPercent, window, reset, extra limit ids).
Local CLI check: Claude `auth status` and `claude -p /usage` do not
publish remaining plan quota. Grok has no usage command. OpenCode
`stats` is historical cost, not remaining. Those rows stay links.
---
src-tauri/src/commands/mod.rs | 1 +
src-tauri/src/commands/subscription_quota.rs | 255 ++++++++++++++++++
src-tauri/src/lib.rs | 2 +
src-tauri/src/web/handlers/mod.rs | 1 +
.../src/web/handlers/subscription_quota.rs | 10 +
src-tauri/src/web/router.rs | 4 +
.../token-usage/subscription-quota-panel.tsx | 110 ++++++--
src/i18n/messages/ar.json | 3 +
src/i18n/messages/de.json | 3 +
src/i18n/messages/en.json | 3 +
src/i18n/messages/es.json | 3 +
src/i18n/messages/fr.json | 3 +
src/i18n/messages/ja.json | 3 +
src/i18n/messages/ko.json | 3 +
src/i18n/messages/pt.json | 3 +
src/i18n/messages/zh-CN.json | 3 +
src/i18n/messages/zh-TW.json | 3 +
src/lib/api.ts | 11 +
src/lib/subscription-quota.test.ts | 61 ++++-
src/lib/subscription-quota.ts | 98 ++++++-
20 files changed, 539 insertions(+), 44 deletions(-)
create mode 100644 src-tauri/src/commands/subscription_quota.rs
create mode 100644 src-tauri/src/web/handlers/subscription_quota.rs
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index dcf009cc3..5c4d46607 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -34,6 +34,7 @@ pub mod remote_proxy;
pub mod remote_workspace;
pub mod science;
pub mod session_info;
+pub mod subscription_quota;
pub mod system_settings;
pub mod terminal;
pub mod token_usage;
diff --git a/src-tauri/src/commands/subscription_quota.rs b/src-tauri/src/commands/subscription_quota.rs
new file mode 100644
index 000000000..1129f636a
--- /dev/null
+++ b/src-tauri/src/commands/subscription_quota.rs
@@ -0,0 +1,255 @@
+//! Official remaining-subscription reads.
+//!
+//! Codex publishes remaining plan quota through the documented app-server
+//! JSON-RPC method `account/rateLimits/read`. This module talks to that
+//! method over `codex app-server --stdio` and returns the official `result`
+//! object. It never invents a remaining number.
+//!
+//! Claude / Grok / Gemini / OpenCode have no matching official remaining
+//! payload on this host (verified against the live CLIs). Those families
+//! stay unavailable at the TypeScript inventory layer.
+
+use std::process::Stdio;
+use std::time::Duration;
+
+use serde::Serialize;
+use serde_json::{json, Value};
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::time::timeout;
+
+use crate::app_error::{AppCommandError, AppErrorCode};
+
+const READ_DEADLINE: Duration = Duration::from_secs(12);
+const INIT_ID: u64 = 1;
+const LIMITS_ID: u64 = 2;
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OfficialQuotaRead {
+ pub family: &'static str,
+ /// Official JSON from the CLI, or `null` when that CLI did not publish
+ /// a remaining-quota payload. Missing CLI is not an error.
+ pub payload: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub unavailable_reason: Option,
+}
+
+pub fn extract_rate_limits_result(messages: &[Value]) -> Option {
+ for message in messages {
+ let Some(obj) = message.as_object() else {
+ continue;
+ };
+ if obj.get("id").and_then(Value::as_u64) != Some(LIMITS_ID) {
+ continue;
+ }
+ if obj.contains_key("error") {
+ return None;
+ }
+ if let Some(result) = obj.get("result") {
+ if result.get("rateLimits").is_some() {
+ return Some(result.clone());
+ }
+ }
+ }
+ None
+}
+
+fn initialize_request() -> Value {
+ json!({
+ "jsonrpc": "2.0",
+ "id": INIT_ID,
+ "method": "initialize",
+ "params": {
+ "clientInfo": {
+ "name": "codeg",
+ "version": env!("CARGO_PKG_VERSION")
+ },
+ "capabilities": {}
+ }
+ })
+}
+
+fn rate_limits_request() -> Value {
+ json!({
+ "jsonrpc": "2.0",
+ "id": LIMITS_ID,
+ "method": "account/rateLimits/read",
+ "params": {}
+ })
+}
+
+async fn read_codex_rate_limits_from_child() -> Result
-
+
+
)}
@@ -2599,6 +2600,7 @@ export function ConversationDetailPanel() {
folderPath={activeTabFolder?.path}
title={activeTab.title}
status={activeTab.status as ConversationStatus | undefined}
+ agentType={activeTab.agentType}
/>
)}
diff --git a/src/components/conversations/session-quota-chip.tsx b/src/components/conversations/session-quota-chip.tsx
new file mode 100644
index 000000000..ae28377bd
--- /dev/null
+++ b/src/components/conversations/session-quota-chip.tsx
@@ -0,0 +1,170 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import { useLocale, useTranslations } from "next-intl"
+import { Gauge } from "lucide-react"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Progress } from "@/components/ui/progress"
+import {
+ subscriptionQuotaClaude,
+ subscriptionQuotaCodex,
+ subscriptionQuotaGrok,
+} from "@/lib/api"
+import {
+ familyFromAgentType,
+ familyQuota,
+ type FamilyQuota,
+ type IsolatableFamily,
+ type OfficialQuotaSlot,
+} from "@/lib/subscription-quota"
+import type { AgentType } from "@/lib/types"
+
+/**
+ * Quiet remaining-quota chip for the current conversation header.
+ * Same official sources as the Token Usage page; one family only so the
+ * chat view does not become a second dashboard.
+ */
+export function SessionQuotaChip({ agentType }: { agentType: AgentType }) {
+ const family = familyFromAgentType(agentType)
+ if (!family) return null
+ return
+}
+
+const QUOTA_FETCHERS: Record<
+ IsolatableFamily,
+ | (() => Promise<{ payload?: unknown; extraSlots?: OfficialQuotaSlot[] }>)
+ | null
+> = {
+ claude: subscriptionQuotaClaude,
+ codex: subscriptionQuotaCodex,
+ grok: subscriptionQuotaGrok,
+ gemini: null,
+ opencode: null,
+}
+
+function SessionQuotaChipInner({ family }: { family: IsolatableFamily }) {
+ const t = useTranslations("TokenUsage")
+ const locale = useLocale()
+ const fetchQuota = QUOTA_FETCHERS[family]
+ const [fetched, setFetched] = useState(null)
+ const [fetchDone, setFetchDone] = useState(false)
+
+ useEffect(() => {
+ if (!fetchQuota) return
+ let cancelled = false
+ void fetchQuota()
+ .then((value) => {
+ if (cancelled) return
+ setFetched(
+ familyQuota(family, value.payload, undefined, value.extraSlots)
+ )
+ })
+ .catch(() => {
+ if (!cancelled) setFetched(familyQuota(family))
+ })
+ .finally(() => {
+ if (!cancelled) setFetchDone(true)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [family, fetchQuota])
+
+ // A family with no official endpoint has nothing to wait for, so its row is
+ // derived during render rather than pushed through state by the effect.
+ const localRow = useMemo(
+ () => (fetchQuota ? null : familyQuota(family)),
+ [family, fetchQuota]
+ )
+ const row = fetchQuota ? fetched : localRow
+ const loaded = fetchQuota ? fetchDone : true
+
+ const label = useMemo(() => {
+ if (!loaded || !row) return t("quotaLoading")
+ if (row.kind === "remaining-subscription") {
+ return t("quotaRemaining", {
+ remaining: Math.round(row.remaining),
+ limit: row.limit,
+ })
+ }
+ return t("quotaTitle")
+ }, [loaded, row, t])
+
+ const windows =
+ row?.kind === "remaining-subscription"
+ ? [
+ {
+ label: row.planType ?? family,
+ remaining: row.remaining,
+ usedPercent: Math.max(0, Math.min(100, 100 - row.remaining)),
+ resetsAt: row.resetsAt,
+ },
+ ...(row.extras ?? []),
+ ]
+ : []
+
+ return (
+
+
+
+
+
+