diff --git a/Shared/UsageViews.swift b/Shared/UsageViews.swift index 95b13b7..1f68d6c 100644 --- a/Shared/UsageViews.swift +++ b/Shared/UsageViews.swift @@ -184,13 +184,17 @@ struct StatsSection: View { struct UsageBar: View { @Environment(\.palette) private var palette + @Environment(\.widgetRenderingMode) private var renderingMode let percent: Int var height: CGFloat = 6 var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { - Capsule().fill(palette.track) - Capsule().fill(severityColor(percent)) + // 单色模式下只用同一灰阶的透明度做层次:深色轨道 + 浅色填充。 + // 不放入 accent group,避免它在不同桌面色调下重新变成实心色块。 + Capsule().fill(renderingMode == .fullColor ? palette.track : Color.white.opacity(0.24)) + Capsule() + .fill(renderingMode == .fullColor ? severityColor(percent) : Color.white.opacity(0.92)) .frame(width: max(geo.size.width * CGFloat(min(max(percent, 0), 100)) / 100.0, percent > 0 ? 3 : 0)) } @@ -201,19 +205,30 @@ struct UsageBar: View { struct ProductHeader: View { @Environment(\.palette) private var palette + @Environment(\.widgetRenderingMode) private var renderingMode let name: String let color: Color var plan: String? = nil var body: some View { HStack(spacing: 6) { + // 产品色点在全部模式中保留,方便快速区分 Claude Code 与 Codex。 Circle().fill(color).frame(width: 7, height: 7) Text(name).font(.system(size: 12, weight: .medium)).foregroundColor(palette.textPrimary) - if let plan = plan { - Text(plan.capitalized) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(palette.secondary) - .padding(.horizontal, 5).padding(.vertical, 1) - .background(Capsule().fill(palette.track)) + if let plan, !plan.isEmpty { + if renderingMode == .fullColor { + Text(plan.capitalized) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(palette.secondary) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(palette.track)) + } else { + // vibrant 会把文字和不透明胶囊都映射成同一灰阶,文字随即消失。 + // 单色状态改为无底色的明确文本,保留套餐/消耗数据的可读性。 + Text(plan.capitalized) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.primary) + .opacity(0.88) + } } Spacer(minLength: 0) } diff --git a/Widget/UsageWidget.swift b/Widget/UsageWidget.swift index ea45643..edadcba 100644 --- a/Widget/UsageWidget.swift +++ b/Widget/UsageWidget.swift @@ -47,6 +47,7 @@ struct UsageProvider: TimelineProvider { struct UsageWidgetEntryView: View { @Environment(\.widgetFamily) private var family @Environment(\.colorScheme) private var systemScheme + @Environment(\.widgetRenderingMode) private var renderingMode let entry: UsageEntry private var palette: Palette { @@ -67,10 +68,15 @@ struct UsageWidgetEntryView: View { } Button(intent: RefreshUsageIntent()) { Image(systemName: "arrow.clockwise") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(palette.secondary) - .padding(5) - .background(Circle().fill(palette.track)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(renderingMode == .fullColor ? palette.secondary : .primary) + .frame(width: 22, height: 22) + .background { + if renderingMode == .fullColor { + Circle().fill(palette.track) + } + } + .contentShape(Circle()) } .buttonStyle(.plain) .padding(7) diff --git a/agent/usage-agent.py b/agent/usage-agent.py index 11fd2bf..c405611 100644 --- a/agent/usage-agent.py +++ b/agent/usage-agent.py @@ -75,9 +75,23 @@ def iso_to_unix(s): CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" def read_codex(opener): - """优先调实时接口(当前账号权威值),失败再回退本地文件扫描。""" + """优先使用完整的实时窗口;不完整响应才回退本地一周快照。""" live = _codex_live(opener) - return live if live is not None else _codex_from_files() + # 未能从实时接口识别出一周窗口时,才使用本地兜底。_codex_live 会根据 + # limit_window_seconds 处理「primary 本身就是一周」的账号响应。 + if live is not None and live.get("five_hour") is not None and live.get("seven_day") is not None: + return live + + fallback = _codex_from_files() + if fallback.get("ok"): + if live and live.get("plan"): + fallback["plan"] = live["plan"] + return fallback + + if live is not None: + live["five_hour"] = {"percent": 0, "resets_at": None} + return live + return fallback def _codex_account_id(auth): @@ -106,10 +120,21 @@ def _codex_live(opener): req = urllib.request.Request(CODEX_USAGE_URL, headers=headers) j = _open_json(opener, req) rl = j.get("rate_limit") or {} + primary = rl.get("primary_window") + secondary = rl.get("secondary_window") + + # 有些账号只返回一个 7 天窗口,却放在 primary_window(secondary 为 null)。 + # 不能按字段名把它展示成 5 小时;按窗口时长映射到一周,并将 5 小时归零。 + if _codex_window_seconds(primary) >= 6 * 24 * 3600: + five_hour = {"percent": 0, "resets_at": None} + seven_day = _codex_live_win(primary) + else: + five_hour = _codex_live_win(primary) + seven_day = _codex_live_win(secondary) return {"ok": True, "plan": j.get("plan_type"), - "five_hour": _codex_live_win(rl.get("primary_window")), - "seven_day": _codex_live_win(rl.get("secondary_window"))} + "five_hour": five_hour, + "seven_day": seven_day} except Exception: return None @@ -122,13 +147,25 @@ def _codex_live_win(w): "resets_at": int(ra) if ra else None} +def _codex_window_seconds(w): + if not isinstance(w, dict): + return 0 + try: + return int(w.get("limit_window_seconds") or 0) + except (TypeError, ValueError): + return 0 + + def _codex_from_files(): - """回退:全局取「事件时间戳最新」的可用 primary/secondary。 + """回退:只使用本地快照中的一周窗口。 多个并发会话时,文件 mtime 最新的那条可能是额度耗尽写的 primary/secondary 全 null 的退化记录(limit_id="premium"),或比别的会话更旧。所以按每条 rate_limits 事件自身 - 的 timestamp 全局取最新。注意:本地文件不带账号 id,多账号时可能串到别的账号, - 仅作为实时接口失败时的兜底。 + 的 timestamp 全局取最新。注意:本地文件不带账号 id,多账号时可能串到别的账号。 + + primary(5 小时)是 CLI 写入的临时限额快照,实时接口不可用时不能可靠代表当前 + 窗口;此降级路径固定返回 0%,避免把过期的临时用量误报为实时消耗。secondary + (一周)仍按最新可用快照显示。 """ pattern = os.path.expanduser("~/.codex/sessions/**/*.jsonl") files = sorted(glob.glob(pattern, recursive=True), key=os.path.getmtime, reverse=True) @@ -158,7 +195,7 @@ def _codex_from_files(): if best_p[1] is None and best_s[1] is None: return {"ok": False, "error": "无数据(还没用过 Codex?)"} return {"ok": True, - "five_hour": _codex_win(best_p[1]), + "five_hour": {"percent": 0, "resets_at": None}, "seven_day": _codex_win(best_s[1]), "plan": _codex_plan()}