diff --git a/plugin/quota_page.go b/plugin/quota_page.go
index e64966e..a76809f 100644
--- a/plugin/quota_page.go
+++ b/plugin/quota_page.go
@@ -37,8 +37,9 @@ const QuotaPageHTML = `
--radius-lg: 16px;
}
+ /* Auto mode fallback: when no explicit data-theme is set, follow OS dark preference */
@media (prefers-color-scheme: dark) {
- :root {
+ :root:not([data-theme="light"]):not([data-theme="white"]) {
--bg-page: #0b0f19;
--bg-card: #151d30;
--bg-subtle: #1e293b;
@@ -61,6 +62,53 @@ const QuotaPageHTML = `
}
}
+ /* Explicit dark theme from Management Center */
+ :root[data-theme="dark"] {
+ --bg-page: #0b0f19;
+ --bg-card: #151d30;
+ --bg-subtle: #1e293b;
+ --border-color: #334155;
+ --text-main: #f8fafc;
+ --text-muted: #94a3b8;
+ --text-dim: #64748b;
+ --primary: #60a5fa;
+ --primary-hover: #3b82f6;
+ --primary-subtle: rgba(59, 130, 246, 0.12);
+ --success: #34d399;
+ --success-subtle: rgba(16, 185, 129, 0.12);
+ --warning: #fbbf24;
+ --warning-subtle: rgba(245, 158, 11, 0.12);
+ --danger: #f87171;
+ --danger-subtle: rgba(239, 68, 68, 0.12);
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5);
+ }
+
+ /* Explicit white / light theme from Management Center: overrides OS dark preference */
+ :root[data-theme="white"],
+ :root[data-theme="light"] {
+ --bg-page: #f8fafc;
+ --bg-card: #ffffff;
+ --bg-subtle: #f1f5f9;
+ --border-color: #e2e8f0;
+ --text-main: #0f172a;
+ --text-muted: #64748b;
+ --text-dim: #94a3b8;
+ --primary: #3b82f6;
+ --primary-hover: #2563eb;
+ --primary-subtle: #eff6ff;
+ --success: #10b981;
+ --success-subtle: #ecfdf5;
+ --warning: #f59e0b;
+ --warning-subtle: #fffbeb;
+ --danger: #ef4444;
+ --danger-subtle: #fef2f2;
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ }
+
* {
box-sizing: border-box;
margin: 0;
@@ -141,6 +189,29 @@ const QuotaPageHTML = `
border: 1px solid var(--primary);
}
+ .plan-tag {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 2px 10px;
+ border-radius: 9999px;
+ background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(59, 130, 246, 0.15));
+ color: #8b5cf6;
+ border: 1px solid rgba(139, 92, 246, 0.35);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ letter-spacing: 0.3px;
+ }
+
+ :root[data-theme="dark"] .plan-tag,
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]):not([data-theme="white"]) .plan-tag {
+ background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(96, 165, 250, 0.2));
+ color: #c084fc;
+ border-color: rgba(167, 139, 250, 0.45);
+ }
+ }
+
.brand-subtitle {
font-size: 13px;
color: var(--text-muted);
@@ -588,6 +659,7 @@ const QuotaPageHTML = `
Command Code 配额
v0.1.0
+ Plan: -
CLIProxyAPI 实时限额与 Credits 用量监控
@@ -796,6 +868,7 @@ const QuotaPageHTML = `
const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText");
+ const planBadge = document.getElementById("planBadge");
const valMonthlyCredits = document.getElementById("valMonthlyCredits");
const valOpensourceCredits = document.getElementById("valOpensourceCredits");
@@ -898,6 +971,16 @@ const QuotaPageHTML = `
const credits = data.credits || (data.data && data.data.credits) || {};
const limits = data.window_limits || (data.data && data.data.window_limits) || {};
+ // Plan
+ const plan = data.plan || (data.data && data.data.plan);
+ if (plan) {
+ const planName = plan.name || (typeof plan === "string" ? plan : "Unknown");
+ planBadge.textContent = "Plan: " + planName;
+ planBadge.style.display = "inline-flex";
+ } else {
+ planBadge.style.display = "none";
+ }
+
// Credits
valMonthlyCredits.textContent = formatNumber(credits.monthly_credits);
valOpensourceCredits.textContent = formatNumber(credits.opensource_monthly_credits);
@@ -1062,6 +1145,71 @@ const QuotaPageHTML = `
inputMgmtKey.value = initKey;
}
+ function syncTheme() {
+ let themeSetting = null;
+
+ // 1. Try reading from parent window (if same-origin iframe)
+ try {
+ if (window.parent && window.parent !== window && window.parent.document) {
+ const parentTheme = window.parent.document.documentElement.getAttribute("data-theme");
+ if (parentTheme) {
+ themeSetting = parentTheme;
+ }
+ }
+ } catch (e) {}
+
+ // 2. Try reading from localStorage['cli-proxy-theme']
+ if (!themeSetting) {
+ try {
+ const stored = localStorage.getItem("cli-proxy-theme");
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed && parsed.state) {
+ if (parsed.state.theme) {
+ themeSetting = parsed.state.theme;
+ }
+ if (parsed.state.resolvedTheme && themeSetting === "auto") {
+ themeSetting = parsed.state.resolvedTheme;
+ }
+ }
+ }
+ } catch (e) {}
+ }
+
+ // 3. Fallback to own html attribute
+ if (!themeSetting) {
+ themeSetting = document.documentElement.getAttribute("data-theme");
+ }
+
+ // Apply theme to documentElement
+ const docEl = document.documentElement;
+ if (themeSetting === "dark") {
+ docEl.setAttribute("data-theme", "dark");
+ } else if (themeSetting === "white" || themeSetting === "light") {
+ docEl.setAttribute("data-theme", themeSetting);
+ } else {
+ // auto or unset: check system preference
+ const isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ if (isDark) {
+ docEl.setAttribute("data-theme", "dark");
+ } else {
+ docEl.setAttribute("data-theme", "light");
+ }
+ }
+ }
+
+ // Initialize theme and sync listeners
+ syncTheme();
+ window.addEventListener("storage", (e) => {
+ if (e.key === "cli-proxy-theme") {
+ syncTheme();
+ }
+ });
+ if (window.matchMedia) {
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", syncTheme);
+ }
+ setInterval(syncTheme, 2000);
+
if (!timerInterval) {
timerInterval = setInterval(updateTimers, 1000);
}
diff --git a/plugin/theme.go b/plugin/theme.go
new file mode 100644
index 0000000..d8690a4
--- /dev/null
+++ b/plugin/theme.go
@@ -0,0 +1,79 @@
+package plugin
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// ThemeStorageState represents the zustand-persisted theme state from CLIProxyAPI Management Center.
+type ThemeStorageState struct {
+ State struct {
+ Theme string `json:"theme"`
+ ResolvedTheme string `json:"resolvedTheme"`
+ } `json:"state"`
+ Version int `json:"version"`
+}
+
+// ResolveThemeFromStorage extracts theme configuration from the 'cli-proxy-theme' localStorage JSON.
+// Returns the user-selected theme setting ("auto", "white", "light", "dark") and the resolved theme ("dark" or "light"/"white").
+func ResolveThemeFromStorage(storageJSON string) (theme string, resolved string) {
+ theme = "auto"
+ resolved = "light"
+
+ s := strings.TrimSpace(storageJSON)
+ if s == "" {
+ return theme, resolved
+ }
+
+ // 1. Try zustand persist format
+ var zustandState ThemeStorageState
+ if err := json.Unmarshal([]byte(s), &zustandState); err == nil && (zustandState.State.Theme != "" || zustandState.State.ResolvedTheme != "") {
+ if zustandState.State.Theme != "" {
+ theme = zustandState.State.Theme
+ }
+ if zustandState.State.ResolvedTheme != "" {
+ resolved = zustandState.State.ResolvedTheme
+ } else {
+ switch theme {
+ case "dark":
+ resolved = "dark"
+ case "white", "light":
+ resolved = "light"
+ default:
+ resolved = "light"
+ }
+ }
+ return theme, resolved
+ }
+
+ // 2. Try simple map {"theme": "..."}
+ var simpleMap map[string]any
+ if err := json.Unmarshal([]byte(s), &simpleMap); err == nil {
+ if t, ok := simpleMap["theme"].(string); ok && t != "" {
+ theme = t
+ if r, ok2 := simpleMap["resolvedTheme"].(string); ok2 && r != "" {
+ resolved = r
+ } else if theme == "dark" {
+ resolved = "dark"
+ } else {
+ resolved = "light"
+ }
+ return theme, resolved
+ }
+ }
+
+ // 3. Fallback for raw string literals like `"dark"` or `dark`
+ clean := strings.ToLower(strings.Trim(s, "\" \t\r\n"))
+ switch clean {
+ case "dark":
+ return "dark", "dark"
+ case "white":
+ return "white", "white"
+ case "light":
+ return "light", "light"
+ case "auto":
+ return "auto", "light"
+ }
+
+ return theme, resolved
+}
diff --git a/plugin/theme_test.go b/plugin/theme_test.go
new file mode 100644
index 0000000..a080000
--- /dev/null
+++ b/plugin/theme_test.go
@@ -0,0 +1,93 @@
+package plugin
+
+import (
+ "testing"
+)
+
+func TestResolveThemeFromStorage(t *testing.T) {
+ tests := []struct {
+ name string
+ storageJSON string
+ wantTheme string
+ wantResolved string
+ }{
+ {
+ name: "zustand dark theme",
+ storageJSON: `{"state":{"theme":"dark","resolvedTheme":"dark"},"version":0}`,
+ wantTheme: "dark",
+ wantResolved: "dark",
+ },
+ {
+ name: "zustand white theme",
+ storageJSON: `{"state":{"theme":"white","resolvedTheme":"light"},"version":0}`,
+ wantTheme: "white",
+ wantResolved: "light",
+ },
+ {
+ name: "zustand light theme",
+ storageJSON: `{"state":{"theme":"light","resolvedTheme":"light"},"version":0}`,
+ wantTheme: "light",
+ wantResolved: "light",
+ },
+ {
+ name: "zustand auto theme with resolved dark",
+ storageJSON: `{"state":{"theme":"auto","resolvedTheme":"dark"},"version":0}`,
+ wantTheme: "auto",
+ wantResolved: "dark",
+ },
+ {
+ name: "zustand auto theme with resolved light",
+ storageJSON: `{"state":{"theme":"auto","resolvedTheme":"light"},"version":0}`,
+ wantTheme: "auto",
+ wantResolved: "light",
+ },
+ {
+ name: "simple json dark",
+ storageJSON: `{"theme":"dark"}`,
+ wantTheme: "dark",
+ wantResolved: "dark",
+ },
+ {
+ name: "simple json white",
+ storageJSON: `{"theme":"white"}`,
+ wantTheme: "white",
+ wantResolved: "light",
+ },
+ {
+ name: "raw string dark",
+ storageJSON: `"dark"`,
+ wantTheme: "dark",
+ wantResolved: "dark",
+ },
+ {
+ name: "raw string white",
+ storageJSON: `white`,
+ wantTheme: "white",
+ wantResolved: "white",
+ },
+ {
+ name: "empty string defaults to auto/light",
+ storageJSON: "",
+ wantTheme: "auto",
+ wantResolved: "light",
+ },
+ {
+ name: "invalid json defaults to auto/light",
+ storageJSON: `{not-valid-json`,
+ wantTheme: "auto",
+ wantResolved: "light",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotTheme, gotResolved := ResolveThemeFromStorage(tt.storageJSON)
+ if gotTheme != tt.wantTheme {
+ t.Errorf("theme = %q, want %q", gotTheme, tt.wantTheme)
+ }
+ if gotResolved != tt.wantResolved {
+ t.Errorf("resolved = %q, want %q", gotResolved, tt.wantResolved)
+ }
+ })
+ }
+}
diff --git a/plugin/types.go b/plugin/types.go
index 7d4cc94..86bd2ea 100644
--- a/plugin/types.go
+++ b/plugin/types.go
@@ -225,6 +225,7 @@ type UpstreamCreditsResponse struct {
// UpstreamWindowLimits carries fiveHour and weekly window metrics.
type UpstreamWindowLimits struct {
+ Limited *bool `json:"limited,omitempty"`
FiveHour UpstreamWindowLimit `json:"fiveHour"`
Weekly UpstreamWindowLimit `json:"weekly"`
}
@@ -274,8 +275,15 @@ type UsageWindowLimitsData struct {
Weekly UsageWindowLimitData `json:"weekly"`
}
+// PlanInfo represents inferred Command Code subscription plan details.
+type PlanInfo struct {
+ Name string `json:"name"`
+ Code string `json:"code"`
+}
+
// FormattedUsageData is the complete formatted usage payload.
type FormattedUsageData struct {
+ Plan PlanInfo `json:"plan"`
Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"`
UpdatedAt string `json:"updated_at"`
@@ -284,6 +292,7 @@ type FormattedUsageData struct {
// FormattedUsageResponse is returned by GET /plugins/commandcode/usage and POST /plugins/commandcode/usage.
type FormattedUsageResponse struct {
OK bool `json:"ok"`
+ Plan PlanInfo `json:"plan"`
Data FormattedUsageData `json:"data"`
Credits UsageCreditsData `json:"credits"`
WindowLimits UsageWindowLimitsData `json:"window_limits"`
diff --git a/plugin/usage.go b/plugin/usage.go
index a8da2e3..150c3a7 100644
--- a/plugin/usage.go
+++ b/plugin/usage.go
@@ -163,8 +163,12 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now
windowLimitsData := formatWindowLimits(upstream.WindowLimits, now)
windowLimitsData.Monthly = formatMonthlyWindow(upstream.Credits, summary, now)
+ // Inferred subscription plan
+ planInfo := PlanFromWindowLimits(upstream.WindowLimits.FiveHour.Cap, upstream.WindowLimits.Weekly.Cap, upstream.WindowLimits.Limited)
+
nowRFC := now.Format(time.RFC3339)
data := FormattedUsageData{
+ Plan: planInfo,
Credits: creditsData,
WindowLimits: windowLimitsData,
UpdatedAt: nowRFC,
@@ -172,6 +176,7 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now
return &FormattedUsageResponse{
OK: true,
+ Plan: planInfo,
Data: data,
Credits: creditsData,
WindowLimits: windowLimitsData,
@@ -179,6 +184,69 @@ func ParseAndFormatUsage(raw []byte, summary *UpstreamUsageSummaryResponse, now
}, nil
}
+// PlanFromWindowLimits infers the Command Code subscription plan based on window limit caps.
+//
+// Rules:
+// - windowLimits.limited == false -> Provider plan (pay-as-you-go)
+// - 5h cap=14 && weekly cap=35 -> GOAT plan
+// - 5h cap=16 && weekly cap=40 -> Pro plan
+// - 5h cap=90 && weekly cap=180 -> Max 20× plan
+// - 5h cap=45 && weekly cap=90 -> Max 10× plan
+// - 5h cap=3 && weekly cap=6 -> Go plan
+// - Otherwise -> Unknown
+func PlanFromWindowLimits(fiveHourCap, weeklyCap float64, limited *bool) PlanInfo {
+ if limited != nil && !*limited {
+ return PlanInfo{
+ Name: "Provider",
+ Code: "provider",
+ }
+ }
+
+ match := func(capVal, target float64) bool {
+ return math.Abs(capVal-target) < 0.01
+ }
+
+ if match(fiveHourCap, 14) && match(weeklyCap, 35) {
+ return PlanInfo{
+ Name: "GOAT",
+ Code: "goat",
+ }
+ }
+
+ if match(fiveHourCap, 16) && match(weeklyCap, 40) {
+ return PlanInfo{
+ Name: "Pro",
+ Code: "pro",
+ }
+ }
+
+ if match(fiveHourCap, 90) && match(weeklyCap, 180) {
+ return PlanInfo{
+ Name: "Max 20×",
+ Code: "max_20x",
+ }
+ }
+
+ if match(fiveHourCap, 45) && match(weeklyCap, 90) {
+ return PlanInfo{
+ Name: "Max 10×",
+ Code: "max_10x",
+ }
+ }
+
+ if match(fiveHourCap, 3) && match(weeklyCap, 6) {
+ return PlanInfo{
+ Name: "Go",
+ Code: "go",
+ }
+ }
+
+ return PlanInfo{
+ Name: "Unknown",
+ Code: "unknown",
+ }
+}
+
func formatCredits(credits map[string]any) UsageCreditsData {
data := UsageCreditsData{
Details: credits,
diff --git a/plugin/usage_test.go b/plugin/usage_test.go
index ab2e080..c1a25ca 100644
--- a/plugin/usage_test.go
+++ b/plugin/usage_test.go
@@ -174,6 +174,27 @@ func TestParseAndFormatUsage(t *testing.T) {
if weekly.ResetAt != "2025-03-10T12:00:00Z" {
t.Errorf("Weekly ResetAt = %v, want 2025-03-10T12:00:00Z", weekly.ResetAt)
}
+
+ // Verify inferred plan (cap 25 / 100 is unknown)
+ if usage.Plan.Name != "Unknown" || usage.Plan.Code != "unknown" {
+ t.Errorf("Plan = %+v, want Unknown", usage.Plan)
+ }
+
+ // Verify plan inference for GOAT
+ rawGOAT := []byte(`{
+ "credits": {"monthlyCredits": 100},
+ "windowLimits": {
+ "fiveHour": {"used": 2, "cap": 14},
+ "weekly": {"used": 10, "cap": 35}
+ }
+ }`)
+ usageGOAT, errGOAT := ParseAndFormatUsage(rawGOAT, nil, now)
+ if errGOAT != nil {
+ t.Fatalf("ParseAndFormatUsage GOAT error: %v", errGOAT)
+ }
+ if usageGOAT.Plan.Name != "GOAT" || usageGOAT.Plan.Code != "goat" {
+ t.Errorf("GOAT Plan = %+v, want name=GOAT code=goat", usageGOAT.Plan)
+ }
}
func TestFetchCreditsRaw_FallbackHTTP(t *testing.T) {
@@ -258,3 +279,100 @@ func TestFetchCreditsRaw_HostCaller(t *testing.T) {
t.Errorf("body = %s, want %s", string(body), string(mockResponsePayload))
}
}
+
+func TestPlanFromWindowLimits(t *testing.T) {
+ trueVal := true
+ falseVal := false
+
+ tests := []struct {
+ name string
+ fiveHourCap float64
+ weeklyCap float64
+ limited *bool
+ wantName string
+ wantCode string
+ }{
+ {
+ name: "GOAT plan",
+ fiveHourCap: 14,
+ weeklyCap: 35,
+ limited: &trueVal,
+ wantName: "GOAT",
+ wantCode: "goat",
+ },
+ {
+ name: "Pro plan",
+ fiveHourCap: 16,
+ weeklyCap: 40,
+ limited: nil,
+ wantName: "Pro",
+ wantCode: "pro",
+ },
+ {
+ name: "Max 20x plan",
+ fiveHourCap: 90,
+ weeklyCap: 180,
+ limited: &trueVal,
+ wantName: "Max 20×",
+ wantCode: "max_20x",
+ },
+ {
+ name: "Max 10x plan",
+ fiveHourCap: 45,
+ weeklyCap: 90,
+ limited: nil,
+ wantName: "Max 10×",
+ wantCode: "max_10x",
+ },
+ {
+ name: "Go plan",
+ fiveHourCap: 3,
+ weeklyCap: 6,
+ limited: nil,
+ wantName: "Go",
+ wantCode: "go",
+ },
+ {
+ name: "Provider pay-as-you-go plan",
+ fiveHourCap: 0,
+ weeklyCap: 0,
+ limited: &falseVal,
+ wantName: "Provider",
+ wantCode: "provider",
+ },
+ {
+ name: "Provider plan with caps set but limited=false",
+ fiveHourCap: 14,
+ weeklyCap: 35,
+ limited: &falseVal,
+ wantName: "Provider",
+ wantCode: "provider",
+ },
+ {
+ name: "Float tolerance test",
+ fiveHourCap: 13.999,
+ weeklyCap: 35.001,
+ limited: nil,
+ wantName: "GOAT",
+ wantCode: "goat",
+ },
+ {
+ name: "Unknown caps",
+ fiveHourCap: 10,
+ weeklyCap: 20,
+ limited: nil,
+ wantName: "Unknown",
+ wantCode: "unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := PlanFromWindowLimits(tt.fiveHourCap, tt.weeklyCap, tt.limited)
+ if got.Name != tt.wantName || got.Code != tt.wantCode {
+ t.Errorf("PlanFromWindowLimits(%v, %v, %v) = %+v, want name=%q code=%q",
+ tt.fiveHourCap, tt.weeklyCap, tt.limited, got, tt.wantName, tt.wantCode)
+ }
+ })
+ }
+}