diff --git a/README.md b/README.md index a1593c0..7fe4bef 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,22 @@ 一只住在 macOS 菜单栏里的 Codex 额度糯米猫。 -它默认显示五小时窗口的**剩余额度**,点击可查看五小时和每周额度、重置时间与刷新状态。额度越紧张,猫的动作越快;读取失败时不会编造数字,而会保留并标记上次的可用结果。 +它在菜单栏显示周额度中更紧张的**剩余额度**,点击可在紧凑的 `348 × 404` 弹窗中查看会员、主周窗口、储备重置卡、今日 Mac 原始 Token、最近速度与刷新状态。额度消耗越快,冒出的猫粮越密集;高速时带火花,最高档会猫粮与火花直溅。读取失败时不会编造数字,而会保留并标记上次的可用结果。 -> A tiny animated macOS menu bar companion for Codex quota. It shows honest five-hour and weekly remaining usage, reset times, and clear stale/error states using your existing local Codex login. +> A tiny animated macOS menu bar companion for Codex quota. Its compact popover shows membership, the primary weekly window, reset credits, today's raw Tokens and recent local speed, plus clear stale/error states using your existing Codex login. 灵感来自 [RunCat](https://apps.apple.com/app/runcat/id1429033973) 的环境式状态反馈,以及 [Quota Float](https://github.com/change-42-yhmm/quota-float) 的本地优先额度读取边界。本项目使用独立实现与原创的糯米猫界面。 ## 功能 - 菜单栏实时剩余百分比 -- 会眨眼、跳动并随额度紧张而加速的糯米猫 -- 五小时和每周额度卡片及重置倒计时 +- 可点击互动、会轮换爱心/星星/害羞等表情的糯米猫 +- 8 套可持久保存的柔色主题:薄荷、晴空、薰衣草、樱花、蜜桃、奶油、燕麦、雾蓝 +- Plus / Pro 会员徽章;Pro 5x 与 Pro 20x 可点击徽章手动指定并保存在本机 +- 主周额度卡片、重置倒计时与储备重置卡数量 +- 从本机 Codex 会话日志统计今日输入、缓存输入、输出与总 Token +- 最近 15 分钟 Token 速度,以及从“偶尔冒粮”逐级升级到“火花直溅”的爆猫粮动效 +- 无多余留白的 `348 × 404` 紧凑弹窗,以及清晰带框的刷新/退出按钮 - 60 秒自动刷新、手动刷新与诚实的异常状态 - 原生 Swift / AppKit / SwiftUI,无第三方运行时依赖 - 本地优先、无遥测、不持久化 Codex 凭据 @@ -28,7 +33,7 @@ open dist/CodexMochi.app ``` -构建完成后也可以把 `dist/CodexMochi.app` 拖入“应用程序”文件夹。它是菜单栏应用,不会显示 Dock 图标;左键打开额度卡片,右键可立即刷新或退出。 +构建完成后也可以把 `dist/CodexMochi.app` 拖入“应用程序”文件夹。它是菜单栏应用,不会显示 Dock 图标;点击菜单栏图标打开额度卡片,再使用底部按钮刷新或退出。 首次打开若被 macOS 拦截,在 Finder 中右键应用并选择“打开”。当前版本使用本机临时签名,未做 Apple 公证。 @@ -47,13 +52,17 @@ swift build -c release - 优先读取 `$CODEX_HOME/auth.json`,否则读取 `~/.codex/auth.json`。 - 现有访问令牌只发送给 `https://chatgpt.com/backend-api/wham/usage`。 +- 重置卡只读取接口随额度返回的数量,不会兑换、消费或修改重置卡。 +- Token 统计只解析 `~/.codex/sessions` 中已有的 `token_count` 事件,不上传或修改会话日志。 - 不保存令牌、账户 ID、原始响应、提示词或聊天历史。 +- 会员倍数的手动选择只保存在本机偏好中,不参与额度或 Token 计算。 - 不包含遥测、统计、自动更新,也不会兑换重置额度或修改账户。 - 每 60 秒刷新一次;网络异常时显示明确状态。 +- 今日 Token 仅覆盖这台 Mac 可见且已持久化的 Codex 会话;它是原始 Token,不等于官方额度 Token。 ## 第一版边界 -目前只支持 Codex,不包含多账户、历史曲线、通知、自启动和主题商店。视觉与功能都刻意保持小而明确,后续可在实际使用后继续调整。 +目前只支持 Codex,不包含多账户、历史曲线、通知、自启动和主题商店。额度接口可自动区分 Plus 与 Pro,但 Pro 账户可能不提供 5x / 20x 子类型,因此应用不会猜测倍数,而是允许本机手动选择。 ## License diff --git a/Sources/CodexMochiApp/MochiIconRenderer.swift b/Sources/CodexMochiApp/MochiIconRenderer.swift index 0c7cfc1..c51ea60 100644 --- a/Sources/CodexMochiApp/MochiIconRenderer.swift +++ b/Sources/CodexMochiApp/MochiIconRenderer.swift @@ -3,6 +3,7 @@ import AppKit enum MochiIconRenderer { static func image( remainingPercent: Double?, + burnUrgency: Int, phase: Int, hasError: Bool, isLoading: Bool @@ -12,13 +13,14 @@ enum MochiIconRenderer { let context = NSGraphicsContext.current?.cgContext context?.setShouldAntialias(true) - let urgency: Int - if hasError { urgency = 0 } - else if let remainingPercent, remainingPercent < 20 { urgency = 3 } - else if let remainingPercent, remainingPercent < 50 { urgency = 2 } - else { urgency = 1 } + let quotaUrgency: Int + if hasError { quotaUrgency = 0 } + else if let remainingPercent, remainingPercent < 20 { quotaUrgency = 3 } + else if let remainingPercent, remainingPercent < 50 { quotaUrgency = 2 } + else { quotaUrgency = 1 } + let urgency = min(4, max(quotaUrgency, burnUrgency)) - let step = urgency == 3 ? phase : (urgency == 2 ? phase / 2 : phase / 4) + let step = urgency >= 3 ? phase : (urgency == 2 ? phase / 2 : phase / 4) let bob = step.isMultiple(of: 2) ? CGFloat(0) : CGFloat(1) let blink = !hasError && !isLoading && phase % 17 == 0 let originY = 2 + bob diff --git a/Sources/CodexMochiApp/PopoverView.swift b/Sources/CodexMochiApp/PopoverView.swift index b660655..b1dd609 100644 --- a/Sources/CodexMochiApp/PopoverView.swift +++ b/Sources/CodexMochiApp/PopoverView.swift @@ -4,99 +4,123 @@ import SwiftUI struct PopoverView: View { @ObservedObject var store: QuotaStore + @AppStorage("mochiTheme") private var storedTheme = MochiThemeID.mint.rawValue + @AppStorage("membershipOverride") private var storedMembershipOverride = MembershipOverride.automatic.rawValue - private var headlineRemaining: Double? { - store.snapshot?.fiveHour?.remainingPercent ?? store.snapshot?.weekly?.remainingPercent + private var theme: MochiThemeID { + MochiThemeID(storedValue: storedTheme) } - private var level: QuotaLevel { - QuotaLevel(remainingPercent: headlineRemaining ?? 100) + private var membershipOverride: MembershipOverride { + MembershipOverride(rawValue: storedMembershipOverride) ?? .automatic + } + + private var membership: MembershipTier { + MembershipTier.resolved( + detected: .detect(plan: store.snapshot?.plan), + override: membershipOverride + ) + } + + private var headlineRemaining: Double? { + store.snapshot?.primaryWeekly?.remainingPercent ?? store.snapshot?.constrainedWeekly?.remainingPercent } var body: some View { VStack(spacing: 0) { header - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 17) + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 10) - VStack(spacing: 10) { + VStack(spacing: 8) { QuotaCard( - eyebrow: "5 小时口粮", - window: store.snapshot?.fiveHour, + eyebrow: "本周额度", + window: store.snapshot?.primaryWeekly, fallback: stateFallback ) - QuotaCard( - eyebrow: "本周储备", - window: store.snapshot?.weekly, - fallback: stateFallback + ResetCreditCard( + available: store.snapshot?.resetCreditsAvailable, + applicable: store.snapshot?.resetCreditsApplicable, + fallback: stateFallback, + theme: theme ) + LocalTokenCard(snapshot: store.localTokens, theme: theme) } - .padding(.horizontal, 14) + .padding(.horizontal, 12) - statusLine - .padding(.horizontal, 20) - .padding(.top, 14) - - Divider().padding(.top, 14) + Color.clear.frame(height: 6) - footer + compactFooter .padding(.horizontal, 14) - .frame(height: 52) + .frame(height: 42) } - .frame(width: 332) + .frame(width: 348, height: 404, alignment: .top) .background(MochiPalette.paper) } private var header: some View { - HStack(spacing: 15) { - MochiFace(remainingPercent: headlineRemaining, hasError: store.error != nil && store.snapshot == nil) - .frame(width: 72, height: 72) - - VStack(alignment: .leading, spacing: 5) { - Text("CODEX MOCHI") - .font(.system(size: 11, weight: .bold, design: .rounded)) - .tracking(1.5) - .foregroundStyle(MochiPalette.secondaryInk) + HStack(spacing: 12) { + InteractiveMochiFace( + remainingPercent: headlineRemaining, + mood: store.pace.mood, + tokenUrgency: store.localTokens.speedMood.animationUrgency, + hasError: store.error != nil && store.snapshot == nil, + theme: theme + ) + .id(store.pace.mood.animationUrgency * 10 + store.localTokens.speedMood.animationUrgency) + .frame(width: 58, height: 58) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("CODEX MOCHI") + .font(.system(size: 9.5, weight: .bold, design: .rounded)) + .tracking(1.1) + MembershipBadge( + membership: membership, + storedOverride: $storedMembershipOverride + ) + if store.pace.mood.animationUrgency >= 3 { + Text("💨") + .font(.system(size: 10)) + } + } + .foregroundStyle(MochiPalette.secondaryInk) Text(headline) - .font(.system(size: 25, weight: .bold, design: .rounded)) + .font(.system(size: 21, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(MochiPalette.ink) Text(headerCaption) - .font(.system(size: 12, weight: .medium, design: .rounded)) + .font(.system(size: 10.5, weight: .medium)) .foregroundStyle(accentColor) + .lineLimit(1) } Spacer(minLength: 0) + + ThemePickerButton(storedTheme: $storedTheme, theme: theme) } } private var headline: String { guard let headlineRemaining else { - return store.isRefreshing ? "正在找口粮…" : "暂时没读到" + return store.isRefreshing ? "正在找周口粮…" : "暂时没读到" } - return "还剩 \(Int(headlineRemaining.rounded()))%" + return "周额度还剩 \(Int(headlineRemaining.rounded()))%" } private var headerCaption: String { if store.isStale { return "上次结果 · 等待恢复" } - if store.isRefreshing { return "正在刷新" } - if let plan = store.snapshot?.plan { return "\(plan) 计划 · 五小时窗口" } - return captionForLevel - } - - private var captionForLevel: String { - switch level { - case .healthy: "慢慢来,口粮充足" - case .caution: "开始认真吃了" - case .critical: "糯米猫有点着急" - case .empty: "等下一轮投喂" - } + if store.isRefreshing { return "正在刷新本周进度" } + if store.pace.mood != .learning { return store.pace.funPhrase } + if let plan = store.snapshot?.plan { return "\(plan) 计划 · 周额度" } + return "正在观察本周额度" } private var accentColor: Color { - MochiPalette.color(for: level) + if store.pace.mood.animationUrgency >= 4 { return MochiPalette.coral } + return MochiPalette.accent(for: theme) } private var stateFallback: String { @@ -104,46 +128,74 @@ struct PopoverView: View { return store.error?.localizedDescription ?? "暂无数据" } - private var statusLine: some View { + private var statusText: String { + if let error = store.error { return error.localizedDescription } + guard let fetchedAt = store.snapshot?.fetchedAt else { return "仅读取本机 Codex 登录,不保存令牌" } + return "更新于 \(fetchedAt.formatted(date: .omitted, time: .shortened)) · 每分钟采样" + } + + private var compactFooter: some View { HStack(spacing: 7) { Circle() .fill(store.isStale || store.error != nil ? MochiPalette.yuzu : MochiPalette.mint) - .frame(width: 7, height: 7) + .frame(width: 6, height: 6) + Text(statusText) - .font(.system(size: 11, weight: .medium, design: .rounded)) + .font(.system(size: 9.5, weight: .medium)) .foregroundStyle(MochiPalette.secondaryInk) - .lineLimit(2) - Spacer() - } - } + .lineLimit(store.error == nil ? 1 : 2) + .minimumScaleFactor(0.8) - private var statusText: String { - if let error = store.error { return error.localizedDescription } - guard let fetchedAt = store.snapshot?.fetchedAt else { return "仅读取本机 Codex 登录,不保存令牌" } - return "更新于 \(fetchedAt.formatted(date: .omitted, time: .shortened)) · 60 秒自动刷新" - } + Spacer(minLength: 3) - private var footer: some View { - HStack(spacing: 8) { Button { Task { await store.refresh() } } label: { - Label(store.isRefreshing ? "刷新中" : "立即刷新", systemImage: "arrow.clockwise") - .font(.system(size: 12, weight: .semibold, design: .rounded)) + Label(store.isRefreshing ? "刷新中" : "刷新", systemImage: "arrow.clockwise") + .font(.system(size: 10, weight: .semibold)) } - .buttonStyle(.borderless) + .buttonStyle(FooterActionButtonStyle(tint: MochiPalette.accent(for: theme))) .disabled(store.isRefreshing) - Spacer() - Button("退出") { NSApp.terminate(nil) } - .buttonStyle(.borderless) - .font(.system(size: 12, weight: .medium, design: .rounded)) - .foregroundStyle(MochiPalette.secondaryInk) + .font(.system(size: 10, weight: .medium)) + .buttonStyle(FooterActionButtonStyle(tint: MochiPalette.secondaryInk)) } } } +private struct FooterActionButtonStyle: ButtonStyle { + let tint: Color + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(tint) + .padding(.horizontal, 8) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(tint.opacity(fillOpacity(isPressed: configuration.isPressed))) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(tint.opacity(strokeOpacity(isPressed: configuration.isPressed)), lineWidth: 0.75) + ) + .opacity(isEnabled ? 1 : 0.55) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + + private func fillOpacity(isPressed: Bool) -> Double { + guard isEnabled else { return 0.05 } + return isPressed ? 0.16 : 0.08 + } + + private func strokeOpacity(isPressed: Bool) -> Double { + guard isEnabled else { return 0.18 } + return isPressed ? 0.42 : 0.26 + } +} + private struct QuotaCard: View { let eyebrow: String let window: QuotaWindow? @@ -154,20 +206,20 @@ private struct QuotaCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 7) { HStack(alignment: .firstTextBaseline) { Text(eyebrow) - .font(.system(size: 12, weight: .bold, design: .rounded)) + .font(.system(size: 11, weight: .bold, design: .rounded)) .foregroundStyle(MochiPalette.secondaryInk) Spacer() if let window { Text("\(Int(window.remainingPercent.rounded()))%") - .font(.system(size: 21, weight: .bold, design: .rounded)) + .font(.system(size: 19, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(MochiPalette.ink) } else { Text("—") - .font(.system(size: 20, weight: .bold, design: .rounded)) + .font(.system(size: 19, weight: .bold, design: .rounded)) .foregroundStyle(MochiPalette.secondaryInk) } } @@ -183,24 +235,26 @@ private struct QuotaCard: View { .frame(height: 8) Text(detailText) - .font(.system(size: 11, weight: .medium, design: .rounded)) + .font(.system(size: 10, weight: .medium)) .foregroundStyle(MochiPalette.secondaryInk) .lineLimit(1) } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(MochiPalette.card) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(MochiPalette.hairline, lineWidth: 1) - ) - ) + .padding(11) + .background(cardBackground) + } + + private var cardBackground: some View { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MochiPalette.card) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(MochiPalette.hairline, lineWidth: 1) + ) } private var detailText: String { guard let window else { return fallback } - guard let reset = window.resetsAt else { return "重置时间暂不可用" } + guard let reset = window.resetsAt else { return "周重置时间暂不可用" } return "\(resetDescription(reset))重置" } @@ -216,70 +270,622 @@ private struct QuotaCard: View { } } -private struct MochiFace: View { +private struct ResetCreditCard: View { + let available: Int? + let applicable: Int? + let fallback: String + let theme: MochiThemeID + + var body: some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MochiPalette.accent(for: theme).opacity(0.17)) + .frame(width: 42, height: 42) + Image(systemName: "rectangle.stack.badge.plus") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(MochiPalette.accent(for: theme)) + } + + VStack(alignment: .leading, spacing: 4) { + Text("还有多少张重置卡") + .font(.system(size: 10.5, weight: .bold)) + .foregroundStyle(MochiPalette.secondaryInk) + Text(available.map { "\($0) 张" } ?? "—") + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(MochiPalette.ink) + } + + Spacer(minLength: 4) + + Text(detailText) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(MochiPalette.accent(for: theme)) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MochiPalette.softCard(for: theme)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(MochiPalette.softHairline(for: theme), lineWidth: 1) + ) + ) + } + + private var detailText: String { + guard available != nil else { return fallback } + guard let applicable else { return "储备重置卡\n当前可使用未知" } + return "储备重置卡\n当前可使用 \(applicable) 张" + } +} + +private struct LocalTokenCard: View { + let snapshot: LocalTokenSnapshot + let theme: MochiThemeID + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Label("今日 Mac 口粮", systemImage: "pawprint.fill") + .font(.system(size: 11, weight: .bold, design: .rounded)) + Spacer() + Text("本机原始 Token") + .font(.system(size: 8.5, weight: .medium)) + } + .foregroundStyle(MochiPalette.secondaryInk) + + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("今日累计") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(MochiPalette.secondaryInk) + Text(todayText) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(MochiPalette.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Divider().frame(height: 38) + + VStack(alignment: .leading, spacing: 3) { + Text("最近 15 分钟") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(MochiPalette.secondaryInk) + Text(rateText) + .font(.system(size: 14.5, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(MochiPalette.ink) + .lineLimit(1) + .minimumScaleFactor(0.65) + Text(snapshot.speedMood.phrase.replacingOccurrences(of: "糯米猫", with: "")) + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(MochiPalette.accent(for: theme)) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + PoppingFoodBowl(mood: snapshot.speedMood, theme: theme) + .frame(width: 52, height: 52) + } + + HStack(spacing: 5) { + Text(breakdownText) + Spacer(minLength: 4) + Text("不等于额度 Token") + } + .font(.system(size: 8.5, weight: .medium)) + .foregroundStyle(MochiPalette.secondaryInk) + .lineLimit(1) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(MochiPalette.softCard(for: theme)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(MochiPalette.softHairline(for: theme), lineWidth: 1) + ) + ) + } + + private var todayText: String { + guard snapshot.sampleCount > 0 else { return "等待记录" } + return TokenCountFormatter.compact(Double(snapshot.today.total)) + } + + private var rateText: String { + guard let rate = snapshot.recentTokensPerHour else { return "观察中" } + if rate == 0 { return "0 / 时" } + return TokenCountFormatter.hourly(rate) + } + + private var breakdownText: String { + guard snapshot.sampleCount > 0 else { return "只读取这台 Mac 的 Codex 日志" } + return "输入 \(TokenCountFormatter.compact(Double(snapshot.today.input)))(缓存 \(TokenCountFormatter.compact(Double(snapshot.today.cachedInput))))· 输出 \(TokenCountFormatter.compact(Double(snapshot.today.output)))" + } +} + +private struct PoppingFoodBowl: View { + let mood: TokenSpeedMood + let theme: MochiThemeID + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let kibbleDirections = [ + CGVector(dx: -15, dy: -24), + CGVector(dx: 13, dy: -29), + CGVector(dx: -24, dy: -18), + CGVector(dx: 23, dy: -20), + CGVector(dx: -6, dy: -34), + CGVector(dx: 28, dy: -11), + CGVector(dx: -29, dy: -10), + CGVector(dx: 6, dy: -36), + ] + + private static let sparkAngles = [-165.0, -140, -115, -90, -65, -40, -15] + + private var style: FoodBurstStyle { + FoodBurstStyle(mood: mood) + } + + var body: some View { + TimelineView(.animation(minimumInterval: reduceMotion ? 1 : 1.0 / 30.0)) { timeline in + ZStack { + if !reduceMotion, let duration = style.cycleDuration { + burstParticles(at: timeline.date, duration: duration) + } + FoodBowlGraphic(theme: theme) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(mood.phrase) + .help(mood.phrase) + } + + @ViewBuilder + private func burstParticles(at date: Date, duration: Double) -> some View { + let cycle = date.timeIntervalSinceReferenceDate / duration + + ForEach(0.. Double { + let stagger = Double(index) / Double(max(1, count)) + return (cycle + stagger).truncatingRemainder(dividingBy: 1) + } +} + +private struct KibbleBurstParticle: View { + let progress: Double + let direction: CGVector + let strength: Double + let rotation: Double + + var body: some View { + let travel = 1 - pow(1 - progress, 2) + let arc = sin(progress * .pi) * 6 + + RoundedRectangle(cornerRadius: 2.5, style: .continuous) + .fill(MochiPalette.kibble) + .frame(width: 7, height: 5) + .rotationEffect(.degrees(rotation + progress * 150)) + .scaleEffect(max(0.55, 1 - progress * 0.38)) + .offset( + x: direction.dx * travel * strength, + y: -8 + direction.dy * travel * strength - arc + ) + .opacity(particleOpacity) + } + + private var particleOpacity: Double { + if progress < 0.1 { return progress / 0.1 } + return max(0, min(1, (1 - progress) / 0.32)) + } +} + +private struct SparkBurstParticle: View { + let progress: Double + let angle: Double + let strength: Double + let isFirework: Bool + let color: Color + + var body: some View { + let radians = angle * .pi / 180 + let travel = 1 - pow(1 - progress, 2) + let distance = (isFirework ? 31.0 : 21.0) * strength + + Capsule() + .fill(color) + .frame(width: isFirework ? 13 : 7, height: isFirework ? 2.5 : 2) + .rotationEffect(.degrees(angle)) + .offset( + x: cos(radians) * distance * travel, + y: -7 + sin(radians) * distance * travel + ) + .opacity(sparkOpacity) + } + + private var sparkOpacity: Double { + if progress < 0.08 { return progress / 0.08 } + return max(0, min(1, (1 - progress) / 0.24)) + } +} + +private struct FoodBowlGraphic: View { + let theme: MochiThemeID + + var body: some View { + ZStack { + Ellipse() + .fill(MochiPalette.ink.opacity(0.08)) + .frame(width: 39, height: 8) + .offset(y: 18) + + FoodBowlShape() + .fill(MochiPalette.accent(for: theme)) + .frame(width: 43, height: 27) + .offset(y: 8) + + Ellipse() + .fill(MochiPalette.paper) + .frame(width: 40, height: 16) + .overlay( + Ellipse().stroke(MochiPalette.accent(for: theme).opacity(0.65), lineWidth: 2) + ) + .offset(y: -3) + + ZStack { + Circle().frame(width: 7, height: 7).offset(x: -11, y: -4) + Circle().frame(width: 8, height: 8).offset(x: 0, y: 0) + Circle().frame(width: 7, height: 7).offset(x: 11, y: -4) + Circle().frame(width: 6, height: 6).offset(x: -5, y: -8) + Circle().frame(width: 6, height: 6).offset(x: 7, y: -8) + } + .foregroundStyle(MochiPalette.kibble) + + Image(systemName: "pawprint.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white.opacity(0.9)) + .offset(y: 10) + } + } +} + +private struct FoodBowlShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.width * 0.76, y: rect.maxY), + control: CGPoint(x: rect.width * 0.94, y: rect.height * 0.92) + ) + path.addQuadCurve( + to: CGPoint(x: rect.width * 0.24, y: rect.maxY), + control: CGPoint(x: rect.midX, y: rect.height * 1.04) + ) + path.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.minY), + control: CGPoint(x: rect.width * 0.06, y: rect.height * 0.92) + ) + path.closeSubpath() + return path + } +} + +private struct MembershipBadge: View { + let membership: MembershipTier + @Binding var storedOverride: String + + var body: some View { + Menu { + ForEach(MembershipOverride.allCases, id: \.rawValue) { choice in + Button { + storedOverride = choice.rawValue + } label: { + Label( + menuLabel(for: choice), + systemImage: currentOverride == choice ? "checkmark.circle.fill" : menuIcon(for: choice) + ) + } + } + } label: { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 7.5, weight: .bold)) + Text(label) + .font(.system(size: 8, weight: .bold, design: .rounded)) + .tracking(0.25) + } + .foregroundStyle(color) + .padding(.horizontal, 6) + .frame(height: 18) + .background( + Capsule().fill(color.opacity(0.14)) + ) + .overlay( + Capsule().stroke(color.opacity(0.28), lineWidth: 0.75) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("会员显示:\(label) · 点击修改") + .accessibilityLabel("会员显示 \(label),点击修改") + } + + private var currentOverride: MembershipOverride { + MembershipOverride(rawValue: storedOverride) ?? .automatic + } + + private var label: String { + switch membership { + case .plus: "PLUS" + case .pro: "PRO" + case .pro5x: "PRO 5×" + case .pro20x: "PRO 20×" + case .member: "MEMBER" + } + } + + private var icon: String { + switch membership { + case .plus: "plus" + case .pro: "diamond.fill" + case .pro5x: "bolt.fill" + case .pro20x: "crown.fill" + case .member: "person.crop.circle" + } + } + + private var color: Color { + switch membership { + case .plus: MochiPalette.membershipMint + case .pro: MochiPalette.membershipFog + case .pro5x: MochiPalette.membershipSky + case .pro20x: MochiPalette.membershipButter + case .member: MochiPalette.membershipFog + } + } + + private func menuLabel(for choice: MembershipOverride) -> String { + switch choice { + case .automatic: "自动识别(当前 \(label))" + case .plus: "Plus" + case .pro5x: "Pro 5x" + case .pro20x: "Pro 20x" + } + } + + private func menuIcon(for choice: MembershipOverride) -> String { + switch choice { + case .automatic: "wand.and.stars" + case .plus: "plus" + case .pro5x: "bolt.fill" + case .pro20x: "crown.fill" + } + } +} + +private struct ThemePickerButton: View { + @Binding var storedTheme: String + let theme: MochiThemeID + + var body: some View { + Menu { + ForEach(MochiThemeID.allCases, id: \.rawValue) { choice in + Button { + storedTheme = choice.rawValue + } label: { + Label( + choice.displayName, + systemImage: choice == theme ? "checkmark.circle.fill" : "circle.fill" + ) + } + } + } label: { + Image(systemName: "paintpalette.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(MochiPalette.accent(for: theme)) + .frame(width: 25, height: 25) + .background( + Circle().fill(MochiPalette.accent(for: theme).opacity(0.14)) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("给糯米猫换一种柔色") + .accessibilityLabel("选择糯米猫颜色") + } +} + +private struct InteractiveMochiFace: View { let remainingPercent: Double? + let mood: BurnMood + let tokenUrgency: Int let hasError: Bool - @State private var bob = false + let theme: MochiThemeID - private var level: QuotaLevel { - QuotaLevel(remainingPercent: remainingPercent ?? 100) + @State private var reaction: CatReaction? + @State private var lastReaction = CatReaction.tongue + @State private var bounce = false + @State private var reactionGeneration = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Button(action: react) { + ZStack { + MochiFace( + remainingPercent: remainingPercent, + mood: mood, + tokenUrgency: tokenUrgency, + hasError: hasError, + theme: theme, + reaction: reaction + ) + + if let reaction { + ReactionSprinkles(reaction: reaction, theme: theme) + .transition(.scale.combined(with: .opacity)) + } + } + .scaleEffect(bounce ? 1.1 : 1) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .help("摸摸糯米猫") + .accessibilityLabel("摸摸糯米猫,让它做表情") } + private func react() { + guard !hasError else { return } + let nextReaction = lastReaction.next + lastReaction = nextReaction + reactionGeneration += 1 + let generation = reactionGeneration + + withAnimation(reduceMotion ? nil : .spring(response: 0.24, dampingFraction: 0.55)) { + reaction = nextReaction + bounce = !reduceMotion + } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 220_000_000) + guard generation == reactionGeneration else { return } + withAnimation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 0.72)) { + bounce = false + } + try? await Task.sleep(nanoseconds: 1_280_000_000) + guard generation == reactionGeneration else { return } + withAnimation(.easeOut(duration: 0.2)) { + reaction = nil + } + } + } +} + +private struct ReactionSprinkles: View { + let reaction: CatReaction + let theme: MochiThemeID + + var body: some View { + ZStack { + Text(reaction == .stars ? "✦" : "♥") + .offset(x: -32, y: -32) + Text(reaction == .grin ? "✦" : "·") + .offset(x: 34, y: -20) + Text(reaction == .blush ? "♡" : "✦") + .offset(x: 30, y: 28) + } + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(MochiPalette.accent(for: theme)) + .allowsHitTesting(false) + } +} + +private struct MochiFace: View { + let remainingPercent: Double? + let mood: BurnMood + let tokenUrgency: Int + let hasError: Bool + let theme: MochiThemeID + let reaction: CatReaction? + @State private var bob = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + private var speed: Double { - switch level { - case .healthy: 1.25 - case .caution: 0.75 - case .critical, .empty: 0.38 + switch max(mood.animationUrgency, tokenUrgency) { + case 0: 1.45 + case 1: 1.1 + case 2: 0.75 + case 3: 0.4 + default: 0.22 } } var body: some View { ZStack { - MochiEar().fill(MochiPalette.color(for: level).opacity(0.95)) + MochiEar().fill(MochiPalette.accent(for: theme).opacity(0.95)) .frame(width: 24, height: 25) .rotationEffect(.degrees(-10)) .offset(x: -20, y: -23) - MochiEar().fill(MochiPalette.color(for: level).opacity(0.95)) + MochiEar().fill(MochiPalette.accent(for: theme).opacity(0.95)) .frame(width: 24, height: 25) .scaleEffect(x: -1, y: 1) .rotationEffect(.degrees(10)) .offset(x: 20, y: -23) RoundedRectangle(cornerRadius: 25, style: .continuous) - .fill(MochiPalette.color(for: level).opacity(0.95)) + .fill(MochiPalette.accent(for: theme).opacity(0.95)) .overlay( RoundedRectangle(cornerRadius: 25, style: .continuous) .stroke(MochiPalette.ink.opacity(0.12), lineWidth: 1) ) if hasError { - HStack(spacing: 16) { - Text("×"); Text("×") + HStack(spacing: 16) { Text("×"); Text("×") } + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(MochiPalette.ink) + } else if let reaction { + VStack(spacing: 1) { + Text(reaction.eyes) + .font(.system(size: 14, weight: .bold, design: .rounded)) + Text(reaction.mouth) + .font(.system(size: 13, weight: .bold, design: .rounded)) } - .font(.system(size: 17, weight: .bold, design: .rounded)) .foregroundStyle(MochiPalette.ink) + .offset(y: 3) } else { - HStack(spacing: 17) { - Capsule(); Capsule() - } - .frame(width: 30, height: 7) - .foregroundStyle(MochiPalette.ink) - .offset(y: -2) + HStack(spacing: 17) { Capsule(); Capsule() } + .frame(width: 30, height: mood == .resting ? 2 : 7) + .foregroundStyle(MochiPalette.ink) + .offset(y: -2) } - Circle() - .trim(from: 0, to: 0.5) - .stroke(MochiPalette.ink, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .frame(width: 12, height: 8) - .rotationEffect(.degrees(0)) - .offset(y: 13) + if reaction == nil { + Circle() + .trim(from: 0, to: 0.5) + .stroke(MochiPalette.ink, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 12, height: 8) + .offset(y: 13) + } } .offset(y: bob ? -2 : 2) .onAppear { + guard !reduceMotion else { return } withAnimation(.easeInOut(duration: speed).repeatForever(autoreverses: true)) { bob = true } } - .accessibilityLabel("糯米猫额度状态") + .accessibilityLabel(accessibilityDescription) + } + + private var accessibilityDescription: String { + guard let remainingPercent else { return "糯米猫周额度节奏状态" } + return "糯米猫,主周额度剩余 \(Int(remainingPercent.rounded())) 百分比" } } @@ -308,10 +914,37 @@ private enum MochiPalette { static let card = Color(nsColor: .controlBackgroundColor) static let track = Color(nsColor: .separatorColor).opacity(0.32) static let hairline = Color(nsColor: .separatorColor).opacity(0.4) + static let sky = Color(red: 0.28, green: 0.58, blue: 0.88) static let mint = Color(red: 0.45, green: 0.84, blue: 0.69) static let yuzu = Color(red: 0.94, green: 0.69, blue: 0.22) static let coral = Color(red: 0.96, green: 0.37, blue: 0.34) static let ash = Color(red: 0.56, green: 0.58, blue: 0.61) + static let kibble = Color(red: 0.60, green: 0.38, blue: 0.22) + static let membershipMint = Color(red: 0.45, green: 0.84, blue: 0.69) + static let membershipSky = Color(red: 0.46, green: 0.74, blue: 0.92) + static let membershipButter = Color(red: 0.89, green: 0.79, blue: 0.44) + static let membershipFog = Color(red: 0.66, green: 0.71, blue: 0.77) + + static func accent(for theme: MochiThemeID) -> Color { + switch theme { + case .mint: Color(red: 0.45, green: 0.84, blue: 0.69) + case .sky: Color(red: 0.46, green: 0.74, blue: 0.92) + case .lavender: Color(red: 0.72, green: 0.65, blue: 0.91) + case .sakura: Color(red: 0.92, green: 0.71, blue: 0.78) + case .peach: Color(red: 0.94, green: 0.70, blue: 0.56) + case .butter: Color(red: 0.89, green: 0.79, blue: 0.44) + case .oatmeal: Color(red: 0.79, green: 0.72, blue: 0.62) + case .fog: Color(red: 0.66, green: 0.71, blue: 0.77) + } + } + + static func softCard(for theme: MochiThemeID) -> Color { + accent(for: theme).opacity(0.10) + } + + static func softHairline(for theme: MochiThemeID) -> Color { + accent(for: theme).opacity(0.34) + } static func color(for level: QuotaLevel) -> Color { switch level { diff --git a/Sources/CodexMochiApp/StatusBarController.swift b/Sources/CodexMochiApp/StatusBarController.swift index ff71057..8563c14 100644 --- a/Sources/CodexMochiApp/StatusBarController.swift +++ b/Sources/CodexMochiApp/StatusBarController.swift @@ -40,15 +40,20 @@ final class StatusBarController: NSObject { private func configurePopover() { popover.behavior = .transient popover.animates = true - popover.contentSize = NSSize(width: 332, height: 430) + popover.contentSize = NSSize(width: 348, height: 404) popover.contentViewController = NSHostingController(rootView: PopoverView(store: store)) } private func observeStore() { - store.$snapshot - .combineLatest(store.$error, store.$isRefreshing) + Publishers.CombineLatest4( + store.$snapshot, + store.$error, + store.$isRefreshing, + store.$pace + ) + .combineLatest(store.$localTokens) .receive(on: RunLoop.main) - .sink { [weak self] _, _, _ in self?.updateStatusButton() } + .sink { [weak self] _ in self?.updateStatusButton() } .store(in: &cancellables) } @@ -80,11 +85,14 @@ final class StatusBarController: NSObject { private func updateStatusButton() { guard let button = statusItem.button else { return } - let remaining = store.snapshot?.fiveHour?.remainingPercent - ?? store.snapshot?.weekly?.remainingPercent + let remaining = store.snapshot?.constrainedWeekly?.remainingPercent button.title = " " + StatusTitleFormatter.title(snapshot: store.snapshot, error: store.error) button.image = MochiIconRenderer.image( remainingPercent: remaining, + burnUrgency: max( + store.pace.mood.animationUrgency, + store.localTokens.speedMood.animationUrgency + ), phase: phase, hasError: store.error != nil && store.snapshot == nil, isLoading: store.isRefreshing && store.snapshot == nil @@ -94,9 +102,9 @@ final class StatusBarController: NSObject { private func accessibilityLabel(remaining: Double?) -> String { if let remaining { - return "Codex 五小时额度剩余 \(Int(remaining.rounded())) 百分比" + return "Codex 周额度剩余 \(Int(remaining.rounded())) 百分比,\(store.localTokens.speedMood.phrase)" } - return store.error?.localizedDescription ?? "正在读取 Codex 剩余额度" + return store.error?.localizedDescription ?? "正在读取 Codex 周额度" } @objc private func togglePopover() { diff --git a/Sources/CodexMochiCore/LocalTokenUsage.swift b/Sources/CodexMochiCore/LocalTokenUsage.swift new file mode 100644 index 0000000..ae2c6eb --- /dev/null +++ b/Sources/CodexMochiCore/LocalTokenUsage.swift @@ -0,0 +1,332 @@ +import Foundation + +public struct TokenCounts: Codable, Equatable, Sendable { + public let input: Int64 + public let cachedInput: Int64 + public let output: Int64 + public let reasoningOutput: Int64 + public let total: Int64 + + public init( + input: Int64, + cachedInput: Int64, + output: Int64, + reasoningOutput: Int64, + total: Int64 + ) { + self.input = max(0, input) + self.cachedInput = max(0, cachedInput) + self.output = max(0, output) + self.reasoningOutput = max(0, reasoningOutput) + self.total = max(0, total) + } + + public static let zero = TokenCounts( + input: 0, + cachedInput: 0, + output: 0, + reasoningOutput: 0, + total: 0 + ) + + fileprivate static func + (lhs: TokenCounts, rhs: TokenCounts) -> TokenCounts { + TokenCounts( + input: lhs.input + rhs.input, + cachedInput: lhs.cachedInput + rhs.cachedInput, + output: lhs.output + rhs.output, + reasoningOutput: lhs.reasoningOutput + rhs.reasoningOutput, + total: lhs.total + rhs.total + ) + } + + fileprivate func increment(after previous: TokenCounts?) -> TokenCounts { + guard let previous else { return self } + return TokenCounts( + input: Self.positiveDelta(input, after: previous.input), + cachedInput: Self.positiveDelta(cachedInput, after: previous.cachedInput), + output: Self.positiveDelta(output, after: previous.output), + reasoningOutput: Self.positiveDelta(reasoningOutput, after: previous.reasoningOutput), + total: Self.positiveDelta(total, after: previous.total) + ) + } + + private static func positiveDelta(_ current: Int64, after previous: Int64) -> Int64 { + current >= previous ? current - previous : current + } +} + +public struct LocalTokenEvent: Equatable, Sendable { + public let sessionID: String + public let date: Date + public let cumulative: TokenCounts + + public init(sessionID: String, date: Date, cumulative: TokenCounts) { + self.sessionID = sessionID + self.date = date + self.cumulative = cumulative + } +} + +public enum TokenSpeedMood: Equatable, Sendable { + case learning + case resting + case nibbling + case munching + case gobbling + case flying + + public init(ratePerHour: Double?) { + guard let ratePerHour else { + self = .learning + return + } + switch ratePerHour { + case ...0: self = .resting + case ..<100_000: self = .nibbling + case ..<1_000_000: self = .munching + case ..<5_000_000: self = .gobbling + default: self = .flying + } + } + + public var phrase: String { + switch self { + case .learning: "糯米猫在找今天的饭碗" + case .resting: "糯米猫舔舔爪,碗里很安静" + case .nibbling: "糯米猫正在小口啃饼" + case .munching: "糯米猫认真嚼着口粮" + case .gobbling: "糯米猫抱着饭碗猛吃" + case .flying: "糯米猫把猫粮和火花直溅出去" + } + } + + public var animationUrgency: Int { + switch self { + case .learning, .resting: 0 + case .nibbling: 1 + case .munching: 2 + case .gobbling: 3 + case .flying: 4 + } + } +} + +public struct LocalTokenSnapshot: Equatable, Sendable { + public let today: TokenCounts + public let recentTokensPerHour: Double? + public let speedMood: TokenSpeedMood + public let sampleCount: Int + public let lastUpdatedAt: Date? + + public init( + today: TokenCounts, + recentTokensPerHour: Double?, + speedMood: TokenSpeedMood, + sampleCount: Int, + lastUpdatedAt: Date? + ) { + self.today = today + self.recentTokensPerHour = recentTokensPerHour + self.speedMood = speedMood + self.sampleCount = sampleCount + self.lastUpdatedAt = lastUpdatedAt + } + + public static let empty = LocalTokenSnapshot( + today: .zero, + recentTokensPerHour: nil, + speedMood: .learning, + sampleCount: 0, + lastUpdatedAt: nil + ) +} + +public enum TokenCountFormatter { + public static func compact(_ value: Double) -> String { + let safeValue = max(0, value) + if safeValue >= 100_000_000 { + return scaled(safeValue / 100_000_000, unit: "亿") + } + if safeValue >= 10_000 { + return scaled(safeValue / 10_000, unit: "万") + } + return grouped(Int64(safeValue.rounded())) + } + + public static func hourly(_ value: Double) -> String { + "\(compact(value)) / 时" + } + + private static func scaled(_ value: Double, unit: String) -> String { + let rounded = value.rounded() + if value >= 100 || abs(value - rounded) < 0.05 { + return "\(grouped(Int64(rounded))) \(unit)" + } + return String(format: "%.1f %@", value, unit) + } + + private static func grouped(_ value: Int64) -> String { + let digits = String(value) + var result = "" + for (index, character) in digits.reversed().enumerated() { + if index > 0, index.isMultiple(of: 3) { result.append(",") } + result.append(character) + } + return String(result.reversed()) + } +} + +public enum LocalTokenUsageCalculator { + public static func calculate( + events: [LocalTokenEvent], + now: Date = Date(), + calendar: Calendar = .current, + recentWindow: TimeInterval = 900 + ) -> LocalTokenSnapshot { + let startOfDay = calendar.startOfDay(for: now) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) ?? now + let recentStart = max(startOfDay, now.addingTimeInterval(-recentWindow)) + var datedIncrements: [(date: Date, counts: TokenCounts)] = [] + + for sessionEvents in Dictionary(grouping: events, by: \ .sessionID).values { + var previous: TokenCounts? + for event in sessionEvents + .filter({ $0.date <= now }) + .sorted(by: { $0.date < $1.date }) + { + let increment = event.cumulative.increment(after: previous) + datedIncrements.append((event.date, increment)) + previous = event.cumulative + } + } + + let todayEvents = datedIncrements.filter { + $0.date >= startOfDay && $0.date < endOfDay + } + guard !todayEvents.isEmpty else { return .empty } + + let today = todayEvents.reduce(TokenCounts.zero) { $0 + $1.counts } + let recentTotal = todayEvents + .filter { $0.date >= recentStart } + .reduce(Int64(0)) { $0 + $1.counts.total } + let safeWindow = max(1, recentWindow) + let hourlyRate = Double(recentTotal) * 3_600 / safeWindow + + return LocalTokenSnapshot( + today: today, + recentTokensPerHour: hourlyRate, + speedMood: TokenSpeedMood(ratePerHour: hourlyRate), + sampleCount: todayEvents.count, + lastUpdatedAt: todayEvents.map(\ .date).max() + ) + } +} + +public struct LocalTokenLogReader: Sendable { + public let sessionsRoot: URL + + public static var defaultSessionsRoot: URL { + let environment = ProcessInfo.processInfo.environment + let codexHome = environment["CODEX_HOME"].map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex", isDirectory: true) + return codexHome.appendingPathComponent("sessions", isDirectory: true) + } + + public init(sessionsRoot: URL = Self.defaultSessionsRoot) { + self.sessionsRoot = sessionsRoot + } + + public func read( + now: Date = Date(), + calendar: Calendar = .current, + recentWindow: TimeInterval = 900 + ) -> LocalTokenSnapshot { + LocalTokenUsageCalculator.calculate( + events: readEvents(now: now, calendar: calendar), + now: now, + calendar: calendar, + recentWindow: recentWindow + ) + } + + private func readEvents(now: Date, calendar: Calendar) -> [LocalTokenEvent] { + let startOfDay = calendar.startOfDay(for: now) + let previousDay = calendar.date(byAdding: .day, value: -1, to: startOfDay) ?? startOfDay + let directories = [ + (url: directoryURL(for: previousDay, calendar: calendar), isPreviousDay: true), + (url: directoryURL(for: startOfDay, calendar: calendar), isPreviousDay: false), + ] + var events: [LocalTokenEvent] = [] + + for directory in directories { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory.url, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for file in files where file.pathExtension == "jsonl" { + if directory.isPreviousDay { + let modifiedAt = try? file.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + guard let modifiedAt, modifiedAt >= startOfDay else { continue } + } + guard let contents = try? String(contentsOf: file, encoding: .utf8) else { continue } + for line in contents.split(whereSeparator: \ .isNewline) { + if let event = parse(line: line, sessionID: file.lastPathComponent) { + events.append(event) + } + } + } + } + return events + } + + private func directoryURL(for date: Date, calendar: Calendar) -> URL { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return sessionsRoot + .appendingPathComponent(String(format: "%04d", parts.year ?? 0), isDirectory: true) + .appendingPathComponent(String(format: "%02d", parts.month ?? 0), isDirectory: true) + .appendingPathComponent(String(format: "%02d", parts.day ?? 0), isDirectory: true) + } + + private func parse(line: Substring, sessionID: String) -> LocalTokenEvent? { + guard + let data = String(line).data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + root["type"] as? String == "event_msg", + let payload = root["payload"] as? [String: Any], + payload["type"] as? String == "token_count", + let info = payload["info"] as? [String: Any], + let usage = info["total_token_usage"] as? [String: Any], + let timestamp = root["timestamp"] as? String, + let date = parseDate(timestamp), + let input = integer(usage["input_tokens"]), + let total = integer(usage["total_tokens"]) + else { return nil } + + return LocalTokenEvent( + sessionID: sessionID, + date: date, + cumulative: TokenCounts( + input: input, + cachedInput: integer(usage["cached_input_tokens"]) ?? 0, + output: integer(usage["output_tokens"]) ?? 0, + reasoningOutput: integer(usage["reasoning_output_tokens"]) ?? 0, + total: total + ) + ) + } + + private func integer(_ value: Any?) -> Int64? { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + return number.int64Value + } + + private func parseDate(_ value: String) -> Date? { + let style = Date.ISO8601FormatStyle(includingFractionalSeconds: value.contains(".")) + return try? style.parse(value) + } +} diff --git a/Sources/CodexMochiCore/MembershipPresentation.swift b/Sources/CodexMochiCore/MembershipPresentation.swift new file mode 100644 index 0000000..c1a034a --- /dev/null +++ b/Sources/CodexMochiCore/MembershipPresentation.swift @@ -0,0 +1,36 @@ +public enum MembershipTier: Equatable, Sendable { + case plus + case pro + case pro5x + case pro20x + case member + + public static func detect(plan: String?) -> MembershipTier { + guard let plan else { return .member } + let normalized = plan.lowercased().filter { $0.isLetter || $0.isNumber } + if normalized.contains("pro20x") { return .pro20x } + if normalized.contains("pro5x") { return .pro5x } + if normalized == "pro" { return .pro } + if normalized.contains("plus") { return .plus } + return .member + } + + public static func resolved( + detected: MembershipTier, + override: MembershipOverride + ) -> MembershipTier { + switch override { + case .automatic: detected + case .plus: .plus + case .pro5x: .pro5x + case .pro20x: .pro20x + } + } +} + +public enum MembershipOverride: String, CaseIterable, Equatable, Sendable { + case automatic + case plus + case pro5x + case pro20x +} diff --git a/Sources/CodexMochiCore/MochiPresentation.swift b/Sources/CodexMochiCore/MochiPresentation.swift new file mode 100644 index 0000000..a7ae53a --- /dev/null +++ b/Sources/CodexMochiCore/MochiPresentation.swift @@ -0,0 +1,120 @@ +public enum MochiThemeID: String, CaseIterable, Equatable, Sendable { + case mint + case sky + case lavender + case sakura + case peach + case butter + case oatmeal + case fog + + public init(storedValue: String) { + self = Self(rawValue: storedValue) ?? .mint + } + + public var displayName: String { + switch self { + case .mint: "薄荷" + case .sky: "晴空" + case .lavender: "薰衣草" + case .sakura: "樱花" + case .peach: "蜜桃" + case .butter: "奶油" + case .oatmeal: "燕麦" + case .fog: "雾蓝" + } + } +} + +public enum CatReaction: String, CaseIterable, Equatable, Sendable { + case hearts + case stars + case blush + case grin + case tongue + + public var next: CatReaction { + switch self { + case .hearts: .stars + case .stars: .blush + case .blush: .grin + case .grin: .tongue + case .tongue: .hearts + } + } + + public var eyes: String { + switch self { + case .hearts: "♥ ♥" + case .stars: "✦ ✦" + case .blush: "• •" + case .grin: "⌒ ⌒" + case .tongue: "> <" + } + } + + public var mouth: String { + switch self { + case .hearts, .blush: "ᴗ" + case .stars: "ω" + case .grin: "▽" + case .tongue: "ڡ" + } + } +} + +public enum FoodBurstStyle: Equatable, Sendable { + case still + case tiny + case popping + case sparkling + case firework + + public init(mood: TokenSpeedMood) { + switch mood { + case .learning, .resting: self = .still + case .nibbling: self = .tiny + case .munching: self = .popping + case .gobbling: self = .sparkling + case .flying: self = .firework + } + } + + public var kibbleCount: Int { + switch self { + case .still: 0 + case .tiny: 1 + case .popping: 3 + case .sparkling: 5 + case .firework: 8 + } + } + + public var sparkCount: Int { + switch self { + case .still, .tiny, .popping: 0 + case .sparkling: 3 + case .firework: 7 + } + } + + public var cycleDuration: Double? { + switch self { + case .still: nil + case .tiny: 1.8 + case .popping: 1.25 + case .sparkling: 0.8 + case .firework: 0.48 + } + } + + public var travelStrength: Double { + switch self { + case .still: 0 + case .tiny: 0.45 + case .popping: 0.7 + case .sparkling: 0.95 + case .firework: 1.35 + } + } +} diff --git a/Sources/CodexMochiCore/QuotaModels.swift b/Sources/CodexMochiCore/QuotaModels.swift index e15d0fb..affdff0 100644 --- a/Sources/CodexMochiCore/QuotaModels.swift +++ b/Sources/CodexMochiCore/QuotaModels.swift @@ -40,15 +40,36 @@ public struct QuotaWindow: Equatable, Sendable { public struct QuotaSnapshot: Equatable, Sendable { public let plan: String? - public let fiveHour: QuotaWindow? - public let weekly: QuotaWindow? + public let primaryWeekly: QuotaWindow? + public let secondaryWeekly: QuotaWindow? public let fetchedAt: Date + public let resetCreditsAvailable: Int? + public let resetCreditsApplicable: Int? - public init(plan: String?, fiveHour: QuotaWindow?, weekly: QuotaWindow?, fetchedAt: Date) { + public init( + plan: String?, + primaryWeekly: QuotaWindow?, + secondaryWeekly: QuotaWindow?, + fetchedAt: Date, + resetCreditsAvailable: Int? = nil, + resetCreditsApplicable: Int? = nil + ) { self.plan = plan - self.fiveHour = fiveHour - self.weekly = weekly + self.primaryWeekly = primaryWeekly + self.secondaryWeekly = secondaryWeekly self.fetchedAt = fetchedAt + self.resetCreditsAvailable = resetCreditsAvailable + self.resetCreditsApplicable = resetCreditsApplicable + } + + public var constrainedWeekly: QuotaWindow? { + switch (primaryWeekly, secondaryWeekly) { + case let (primary?, secondary?): + primary.remainingPercent <= secondary.remainingPercent ? primary : secondary + case let (primary?, nil): primary + case let (nil, secondary?): secondary + case (nil, nil): nil + } } } diff --git a/Sources/CodexMochiCore/QuotaParser.swift b/Sources/CodexMochiCore/QuotaParser.swift index 18f850a..f4df649 100644 --- a/Sources/CodexMochiCore/QuotaParser.swift +++ b/Sources/CodexMochiCore/QuotaParser.swift @@ -9,29 +9,42 @@ public enum QuotaParser { } let rateLimit = dictionary(root, keys: ["rate_limit", "rateLimit"]) ?? root - let fiveHour = findWindow( + let primaryWeekly = findWindow( in: rateLimit, - directKeys: ["primary_window", "primaryWindow", "five_hour", "fiveHour", "five_hour_window"], - names: ["5h", "five_hour", "primary"], - expectedSeconds: 18_000 + directKeys: ["primary_window", "primaryWindow", "weekly_primary", "weeklyPrimary"], + names: ["primary", "main", "weekly_primary"], + expectedSeconds: 604_800 ) - let weekly = findWindow( + let secondaryWeekly = findWindow( in: rateLimit, - directKeys: ["secondary_window", "secondaryWindow", "weekly", "weekly_window", "weeklyWindow"], - names: ["weekly", "week", "secondary", "7d"], + directKeys: ["secondary_window", "secondaryWindow", "weekly_secondary", "weeklySecondary"], + names: ["secondary", "backup", "weekly_secondary"], expectedSeconds: 604_800 ) - guard fiveHour != nil || weekly != nil else { + guard primaryWeekly != nil || secondaryWeekly != nil else { throw QuotaError.changedResponse } + let resetCredits = dictionary( + root, + keys: ["rate_limit_reset_credits", "rateLimitResetCredits"] + ) + let resetCreditsAvailable = resetCredits + .flatMap { integer($0, keys: ["available_count", "availableCount"]) } + .map { max(0, $0) } + let resetCreditsApplicable = resetCredits + .flatMap { integer($0, keys: ["applicable_available_count", "applicableAvailableCount"]) } + .map { max(0, $0) } + let rawPlan = string(root, keys: ["plan_type", "planType", "plan"]) return QuotaSnapshot( plan: rawPlan.map(displayPlan), - fiveHour: fiveHour, - weekly: weekly, - fetchedAt: now + primaryWeekly: primaryWeekly, + secondaryWeekly: secondaryWeekly, + fetchedAt: now, + resetCreditsAvailable: resetCreditsAvailable, + resetCreditsApplicable: resetCreditsApplicable ) } @@ -54,7 +67,7 @@ public enum QuotaParser { let label = string(item, keys: ["name", "type", "id", "window", "label"])?.lowercased() let nameMatches = label.map { value in names.contains { value.contains($0) } } ?? false let durationMatches = abs(parsed.windowSeconds - expectedSeconds) <= 60 - if nameMatches || durationMatches { return parsed } + if label != nil ? nameMatches : durationMatches { return parsed } } } return nil diff --git a/Sources/CodexMochiCore/QuotaStore.swift b/Sources/CodexMochiCore/QuotaStore.swift index 3951062..06eb578 100644 --- a/Sources/CodexMochiCore/QuotaStore.swift +++ b/Sources/CodexMochiCore/QuotaStore.swift @@ -7,20 +7,41 @@ public final class QuotaStore: ObservableObject { @Published public private(set) var error: QuotaError? @Published public private(set) var isRefreshing = false @Published public private(set) var isStale = false + @Published public private(set) var pace: UsagePace + @Published public private(set) var localTokens: LocalTokenSnapshot private let fetcher: any QuotaFetching + private let historyRepository: UsageHistoryRepository? + private let tokenSnapshotProvider: @Sendable () -> LocalTokenSnapshot + private var historySamples: [UsageSample] - public init(fetcher: any QuotaFetching = QuotaClient()) { + public init( + fetcher: any QuotaFetching = QuotaClient(), + historyURL: URL? = UsageHistoryRepository.defaultURL, + tokenSnapshotProvider: @escaping @Sendable () -> LocalTokenSnapshot = { + LocalTokenLogReader().read() + } + ) { self.fetcher = fetcher + self.tokenSnapshotProvider = tokenSnapshotProvider + historyRepository = historyURL.map(UsageHistoryRepository.init(fileURL:)) + historySamples = (try? historyRepository?.load()) ?? [] + pace = UsagePaceCalculator.calculate(samples: historySamples) + localTokens = tokenSnapshotProvider() } public func refresh() async { guard !isRefreshing else { return } isRefreshing = true defer { isRefreshing = false } + localTokens = tokenSnapshotProvider() do { - snapshot = try await fetcher.fetch() + let newSnapshot = try await fetcher.fetch() + snapshot = newSnapshot + historySamples = UsageHistoryRecorder.appending(snapshot: newSnapshot, to: historySamples) + pace = UsagePaceCalculator.calculate(samples: historySamples, now: newSnapshot.fetchedAt) + try? historyRepository?.save(historySamples) error = nil isStale = false } catch let quotaError as QuotaError { diff --git a/Sources/CodexMochiCore/StatusTitleFormatter.swift b/Sources/CodexMochiCore/StatusTitleFormatter.swift index 0179d15..01400a0 100644 --- a/Sources/CodexMochiCore/StatusTitleFormatter.swift +++ b/Sources/CodexMochiCore/StatusTitleFormatter.swift @@ -2,7 +2,7 @@ import Foundation public enum StatusTitleFormatter { public static func title(snapshot: QuotaSnapshot?, error: QuotaError?) -> String { - if let percent = snapshot?.fiveHour?.remainingPercent ?? snapshot?.weekly?.remainingPercent { + if let percent = snapshot?.constrainedWeekly?.remainingPercent { return "\(Int(percent.rounded()))%" } return error == nil ? "…" : "!" diff --git a/Sources/CodexMochiCore/UsagePace.swift b/Sources/CodexMochiCore/UsagePace.swift new file mode 100644 index 0000000..e440bdc --- /dev/null +++ b/Sources/CodexMochiCore/UsagePace.swift @@ -0,0 +1,202 @@ +import Foundation + +public struct UsageSample: Codable, Equatable, Sendable { + public let date: Date + public let remainingPercent: Double + + public init(date: Date, remainingPercent: Double) { + self.date = date + self.remainingPercent = min(100, max(0, remainingPercent)) + } +} + +public enum UsageFrequency: Equatable, Sendable { + case learning + case quiet + case occasional + case frequent + case intense + + public var label: String { + switch self { + case .learning: "观察中" + case .quiet: "安安静静" + case .occasional: "偶尔来一口" + case .frequent: "来得挺勤" + case .intense: "几乎没停" + } + } +} + +public enum BurnMood: Equatable, Sendable { + case learning + case resting + case sipping + case munching + case gobbling + case blazing + + public var phrase: String { + switch self { + case .learning: "糯米猫正在闻味道" + case .resting: "糯米猫正在打盹" + case .sipping: "糯米猫在小口抿茶" + case .munching: "糯米猫认真嚼口粮" + case .gobbling: "糯米猫正在狼吞虎咽" + case .blazing: "糯米猫尾巴都冒烟了" + } + } + + public var animationUrgency: Int { + switch self { + case .learning, .resting: 0 + case .sipping: 1 + case .munching: 2 + case .gobbling: 3 + case .blazing: 4 + } + } +} + +public struct UsagePace: Equatable, Sendable { + public let burnRatePerHour: Double? + public let activeIntervals: Int + public let observedIntervals: Int + public let frequency: UsageFrequency + public let mood: BurnMood + public let projectedHoursRemaining: Double? + public let observationMinutes: Int + + public var funPhrase: String { mood.phrase } + + public static let learning = UsagePace( + burnRatePerHour: nil, + activeIntervals: 0, + observedIntervals: 0, + frequency: .learning, + mood: .learning, + projectedHoursRemaining: nil, + observationMinutes: 0 + ) +} + +public enum UsagePaceCalculator { + public static func calculate( + samples: [UsageSample], + now: Date = Date(), + window: TimeInterval = 3_600 + ) -> UsagePace { + let cutoff = now.addingTimeInterval(-window) + let recent = samples + .filter { $0.date >= cutoff && $0.date <= now } + .sorted { $0.date < $1.date } + guard recent.count >= 2 else { return .learning } + + var segmentStart = 0 + for index in 1 ..< recent.count { + if recent[index].remainingPercent - recent[index - 1].remainingPercent >= 1 { + segmentStart = index + } + } + let segment = Array(recent[segmentStart...]) + guard segment.count >= 2 else { return .learning } + + let elapsed = segment.last!.date.timeIntervalSince(segment.first!.date) + guard elapsed >= 30 else { return .learning } + + var burned = 0.0 + var activeIntervals = 0 + for index in 1 ..< segment.count { + let drop = segment[index - 1].remainingPercent - segment[index].remainingPercent + if drop > 0.001 { + burned += drop + activeIntervals += 1 + } + } + + let hourlyRate = burned / (elapsed / 3_600) + let frequency = frequency(activeIntervals: activeIntervals) + let mood = mood(rate: hourlyRate) + let remaining = segment.last?.remainingPercent ?? 0 + let projection = hourlyRate > 0.05 ? remaining / hourlyRate : nil + + return UsagePace( + burnRatePerHour: hourlyRate, + activeIntervals: activeIntervals, + observedIntervals: segment.count - 1, + frequency: frequency, + mood: mood, + projectedHoursRemaining: projection, + observationMinutes: Int((elapsed / 60).rounded()) + ) + } + + private static func frequency(activeIntervals: Int) -> UsageFrequency { + switch activeIntervals { + case 0: .quiet + case 1 ... 2: .occasional + case 3 ... 8: .frequent + default: .intense + } + } + + private static func mood(rate: Double) -> BurnMood { + switch rate { + case ...0.02: .resting + case ..<0.5: .sipping + case ..<2: .munching + case ..<5: .gobbling + default: .blazing + } + } +} + +public enum UsageHistoryRecorder { + public static func appending( + snapshot: QuotaSnapshot, + to samples: [UsageSample], + retention: TimeInterval = 86_400 + ) -> [UsageSample] { + guard let remaining = snapshot.constrainedWeekly?.remainingPercent else { return samples } + let newSample = UsageSample(date: snapshot.fetchedAt, remainingPercent: remaining) + var retained = samples + .filter { $0.date >= snapshot.fetchedAt.addingTimeInterval(-retention) } + .sorted { $0.date < $1.date } + + if let last = retained.last, + snapshot.fetchedAt.timeIntervalSince(last.date) < 15, + abs(last.remainingPercent - remaining) < 0.001 { + return retained + } + retained.append(newSample) + return retained + } +} + +public struct UsageHistoryRepository: Sendable { + public let fileURL: URL + + public static var defaultURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("CodexMochi", isDirectory: true) + .appendingPathComponent("usage-history.json") + } + + public init(fileURL: URL = Self.defaultURL) { + self.fileURL = fileURL + } + + public func load() throws -> [UsageSample] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + return try JSONDecoder().decode([UsageSample].self, from: Data(contentsOf: fileURL)) + } + + public func save(_ samples: [UsageSample]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(samples) + try data.write(to: fileURL, options: .atomic) + } +} diff --git a/Tests/CodexMochiCoreTests/LocalTokenUsageTests.swift b/Tests/CodexMochiCoreTests/LocalTokenUsageTests.swift new file mode 100644 index 0000000..d3c45bd --- /dev/null +++ b/Tests/CodexMochiCoreTests/LocalTokenUsageTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +@testable import CodexMochiCore + +private let shanghaiCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar +}() + +private func tokenCounts(_ total: Int64) -> TokenCounts { + TokenCounts( + input: total, + cachedInput: total / 2, + output: 0, + reasoningOutput: 0, + total: total + ) +} + +@Test func aggregatesPositiveCumulativeDeltasWithoutCountingDuplicates() throws { + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 12 + ))) + let events = [ + LocalTokenEvent(sessionID: "session-a", date: now.addingTimeInterval(-240), cumulative: tokenCounts(100)), + LocalTokenEvent(sessionID: "session-a", date: now.addingTimeInterval(-180), cumulative: tokenCounts(160)), + LocalTokenEvent(sessionID: "session-a", date: now.addingTimeInterval(-120), cumulative: tokenCounts(160)), + LocalTokenEvent(sessionID: "session-a", date: now.addingTimeInterval(-60), cumulative: tokenCounts(25)), + ] + + let snapshot = LocalTokenUsageCalculator.calculate( + events: events, + now: now, + calendar: shanghaiCalendar + ) + + #expect(snapshot.today.total == 185) + #expect(snapshot.today.input == 185) + #expect(snapshot.today.cachedInput == 92) + #expect(snapshot.sampleCount == 4) +} + +@Test func keepsOnlyPositiveDeltasFromTheLocalCalendarDay() throws { + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 0, minute: 10 + ))) + let start = shanghaiCalendar.startOfDay(for: now) + let events = [ + LocalTokenEvent(sessionID: "overnight", date: start.addingTimeInterval(-60), cumulative: tokenCounts(100)), + LocalTokenEvent(sessionID: "overnight", date: start.addingTimeInterval(60), cumulative: tokenCounts(160)), + LocalTokenEvent(sessionID: "today", date: start.addingTimeInterval(120), cumulative: tokenCounts(25)), + ] + + let snapshot = LocalTokenUsageCalculator.calculate( + events: events, + now: now, + calendar: shanghaiCalendar + ) + + #expect(snapshot.today.total == 85) + #expect(snapshot.sampleCount == 2) +} + +@Test func calculatesRecentFifteenMinuteHourlyRate() throws { + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 12 + ))) + let events = [ + LocalTokenEvent(sessionID: "pace", date: now.addingTimeInterval(-1_200), cumulative: tokenCounts(100_000)), + LocalTokenEvent(sessionID: "pace", date: now.addingTimeInterval(-600), cumulative: tokenCounts(200_000)), + LocalTokenEvent(sessionID: "pace", date: now, cumulative: tokenCounts(300_000)), + ] + + let snapshot = LocalTokenUsageCalculator.calculate( + events: events, + now: now, + calendar: shanghaiCalendar, + recentWindow: 900 + ) + + #expect(snapshot.today.total == 300_000) + #expect(snapshot.recentTokensPerHour == 800_000) + #expect(snapshot.speedMood == .munching) +} + +@Test func reportsLearningAndRestingHonestly() throws { + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 12 + ))) + let empty = LocalTokenUsageCalculator.calculate( + events: [], + now: now, + calendar: shanghaiCalendar + ) + let old = LocalTokenUsageCalculator.calculate( + events: [ + LocalTokenEvent( + sessionID: "old", + date: now.addingTimeInterval(-3_600), + cumulative: tokenCounts(50_000) + ), + ], + now: now, + calendar: shanghaiCalendar + ) + + #expect(empty.recentTokensPerHour == nil) + #expect(empty.speedMood == .learning) + #expect(old.recentTokensPerHour == 0) + #expect(old.speedMood == .resting) +} + +@Test func classifiesEveryCuteTokenSpeedBoundary() { + #expect(TokenSpeedMood(ratePerHour: nil) == .learning) + #expect(TokenSpeedMood(ratePerHour: 0) == .resting) + #expect(TokenSpeedMood(ratePerHour: 99_999) == .nibbling) + #expect(TokenSpeedMood(ratePerHour: 100_000) == .munching) + #expect(TokenSpeedMood(ratePerHour: 999_999) == .munching) + #expect(TokenSpeedMood(ratePerHour: 1_000_000) == .gobbling) + #expect(TokenSpeedMood(ratePerHour: 4_999_999) == .gobbling) + #expect(TokenSpeedMood(ratePerHour: 5_000_000) == .flying) + #expect(TokenSpeedMood.flying.phrase.contains("火花直溅")) +} + +@Test func logReaderSkipsMalformedLinesAndReadsTokenEvents() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let dayDirectory = root.appendingPathComponent("2026/07/17", isDirectory: true) + try FileManager.default.createDirectory(at: dayDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let file = dayDirectory.appendingPathComponent("rollout-test.jsonl") + let contents = """ + not-json + {"timestamp":"2026-07-17T04:00:00Z","type":"event_msg","payload":{"type":"agent_message"}} + {"timestamp":"2026-07-17T04:01:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":120,"cached_input_tokens":80,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":140}}}} + """ + try contents.write(to: file, atomically: true, encoding: .utf8) + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 13 + ))) + + let snapshot = LocalTokenLogReader(sessionsRoot: root).read( + now: now, + calendar: shanghaiCalendar + ) + + #expect(snapshot.today.total == 140) + #expect(snapshot.today.cachedInput == 80) + #expect(snapshot.sampleCount == 1) +} + +@Test func missingLogDirectoryReturnsEmptySnapshot() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let now = try #require(shanghaiCalendar.date(from: DateComponents( + year: 2026, month: 7, day: 17, hour: 12 + ))) + + let snapshot = LocalTokenLogReader(sessionsRoot: root).read( + now: now, + calendar: shanghaiCalendar + ) + + #expect(snapshot == .empty) +} + +@Test func formatsLargeTokenCountsForTheMenuBarCard() { + #expect(TokenCountFormatter.compact(9_999) == "9,999") + #expect(TokenCountFormatter.compact(12_345) == "1.2 万") + #expect(TokenCountFormatter.compact(1_280_000) == "128 万") + #expect(TokenCountFormatter.compact(28_900_000) == "2,890 万") + #expect(TokenCountFormatter.compact(100_000_000) == "1 亿") + #expect(TokenCountFormatter.hourly(15_840_000) == "1,584 万 / 时") +} diff --git a/Tests/CodexMochiCoreTests/MembershipPresentationTests.swift b/Tests/CodexMochiCoreTests/MembershipPresentationTests.swift new file mode 100644 index 0000000..240adf2 --- /dev/null +++ b/Tests/CodexMochiCoreTests/MembershipPresentationTests.swift @@ -0,0 +1,25 @@ +import Testing +@testable import CodexMochiCore + +@Test func detectsMembershipWithoutGuessingGenericPro() { + #expect(MembershipTier.detect(plan: "Plus") == .plus) + #expect(MembershipTier.detect(plan: "pro") == .pro) + #expect(MembershipTier.detect(plan: "pro-5x") == .pro5x) + #expect(MembershipTier.detect(plan: "PRO 20X") == .pro20x) + #expect(MembershipTier.detect(plan: "pro_20x") == .pro20x) + #expect(MembershipTier.detect(plan: nil) == .member) + #expect(MembershipTier.detect(plan: "Team") == .member) +} + +@Test func manualMembershipOverrideWinsUntilAutomaticIsRestored() { + #expect(MembershipTier.resolved(detected: .pro, override: .automatic) == .pro) + #expect(MembershipTier.resolved(detected: .pro, override: .pro20x) == .pro20x) + #expect(MembershipTier.resolved(detected: .plus, override: .pro5x) == .pro5x) + #expect(MembershipTier.resolved(detected: .member, override: .plus) == .plus) +} + +@Test func membershipOverridesUseStablePersistedValues() { + #expect(MembershipOverride(rawValue: "automatic") == .automatic) + #expect(MembershipOverride(rawValue: "pro5x") == .pro5x) + #expect(MembershipOverride(rawValue: "pro20x") == .pro20x) +} diff --git a/Tests/CodexMochiCoreTests/MochiPresentationTests.swift b/Tests/CodexMochiCoreTests/MochiPresentationTests.swift new file mode 100644 index 0000000..4e42033 --- /dev/null +++ b/Tests/CodexMochiCoreTests/MochiPresentationTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import CodexMochiCore + +@Test func mochiThemesExposeEightStableChoicesAndSafeFallback() { + #expect(MochiThemeID.allCases.map(\.rawValue) == [ + "mint", "sky", "lavender", "sakura", "peach", "butter", "oatmeal", "fog", + ]) + #expect(MochiThemeID(storedValue: "lavender") == .lavender) + #expect(MochiThemeID(storedValue: "future-theme") == .mint) +} + +@Test func catReactionsCycleWithoutRepeatingTheCurrentFace() { + #expect(CatReaction.hearts.next == .stars) + #expect(CatReaction.stars.next == .blush) + #expect(CatReaction.blush.next == .grin) + #expect(CatReaction.grin.next == .tongue) + #expect(CatReaction.tongue.next == .hearts) +} + +@Test func foodBurstStyleEscalatesFromQuietKibbleToStraightFlyingSparks() { + #expect(FoodBurstStyle(mood: .learning) == .still) + #expect(FoodBurstStyle(mood: .resting) == .still) + #expect(FoodBurstStyle(mood: .nibbling) == .tiny) + #expect(FoodBurstStyle(mood: .munching) == .popping) + #expect(FoodBurstStyle(mood: .gobbling) == .sparkling) + #expect(FoodBurstStyle(mood: .flying) == .firework) + + #expect(FoodBurstStyle.tiny.kibbleCount == 1) + #expect(FoodBurstStyle.popping.kibbleCount == 3) + #expect(FoodBurstStyle.sparkling.kibbleCount == 5) + #expect(FoodBurstStyle.firework.kibbleCount == 8) + #expect(FoodBurstStyle.sparkling.sparkCount == 3) + #expect(FoodBurstStyle.firework.sparkCount == 7) + #expect(FoodBurstStyle.firework.travelStrength > FoodBurstStyle.sparkling.travelStrength) + #expect(FoodBurstStyle.firework.cycleDuration == 0.48) +} diff --git a/Tests/CodexMochiCoreTests/QuotaClientTests.swift b/Tests/CodexMochiCoreTests/QuotaClientTests.swift index 57e7c74..9488cb7 100644 --- a/Tests/CodexMochiCoreTests/QuotaClientTests.swift +++ b/Tests/CodexMochiCoreTests/QuotaClientTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CodexMochiCore @Test func quotaClientSendsCodexHeadersAndParsesUsage() async throws { - let body = Data(#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":22,"limit_window_seconds":18000}}}"#.utf8) + let body = Data(#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":22,"limit_window_seconds":604800}}}"#.utf8) let transport = RecordingTransport(result: .success((body, response(status: 200)))) let client = QuotaClient( transport: transport, @@ -13,7 +13,7 @@ import Testing let snapshot = try await client.fetch() let request = try #require(await transport.lastRequest()) - #expect(snapshot.fiveHour?.remainingPercent == 78) + #expect(snapshot.primaryWeekly?.remainingPercent == 78) #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret-token") #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-Id") == "acct_42") @@ -58,12 +58,16 @@ func quotaClientMapsHTTPFailures(status: Int, expected: QuotaError) async { @Test func quotaStoreKeepsLastSnapshotAndMarksItStaleAfterFailure() async { let snapshot = QuotaSnapshot( plan: "Plus", - fiveHour: QuotaWindow(remainingPercent: 55, resetsAt: nil, windowSeconds: 18_000), - weekly: nil, + primaryWeekly: QuotaWindow(remainingPercent: 55, resetsAt: nil, windowSeconds: 604_800), + secondaryWeekly: nil, fetchedAt: Date() ) let fetcher = SequenceFetcher(results: [.success(snapshot), .failure(.transport)]) - let store = QuotaStore(fetcher: fetcher) + let store = QuotaStore( + fetcher: fetcher, + historyURL: nil, + tokenSnapshotProvider: { .empty } + ) await store.refresh() #expect(store.snapshot == snapshot) @@ -75,6 +79,39 @@ func quotaClientMapsHTTPFailures(status: Int, expected: QuotaError) async { #expect(store.error == .transport) } +@MainActor +@Test func quotaStoreRefreshesLocalTokenSnapshot() async { + let quota = QuotaSnapshot( + plan: "Pro", + primaryWeekly: QuotaWindow(remainingPercent: 98, resetsAt: nil, windowSeconds: 604_800), + secondaryWeekly: nil, + fetchedAt: Date() + ) + let localTokens = LocalTokenSnapshot( + today: TokenCounts( + input: 1_200_000, + cachedInput: 900_000, + output: 80_000, + reasoningOutput: 20_000, + total: 1_280_000 + ), + recentTokensPerHour: 2_400_000, + speedMood: .gobbling, + sampleCount: 12, + lastUpdatedAt: Date() + ) + let store = QuotaStore( + fetcher: SequenceFetcher(results: [.success(quota)]), + historyURL: nil, + tokenSnapshotProvider: { localTokens } + ) + + await store.refresh() + + #expect(store.localTokens == localTokens) + #expect(store.snapshot == quota) +} + private actor RecordingTransport: HTTPTransport { private let result: Result<(Data, HTTPURLResponse), Error> private var request: URLRequest? diff --git a/Tests/CodexMochiCoreTests/QuotaParserTests.swift b/Tests/CodexMochiCoreTests/QuotaParserTests.swift index 9de5437..198ed01 100644 --- a/Tests/CodexMochiCoreTests/QuotaParserTests.swift +++ b/Tests/CodexMochiCoreTests/QuotaParserTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import CodexMochiCore -@Test func parsesUsedPercentAsRemainingAcrossPrimaryAndSecondaryWindows() throws { +@Test func parsesPrimaryAndSecondaryAsWeeklyWindows() throws { let data = Data(#""" { "plan_type": "plus", @@ -10,26 +10,62 @@ import Testing "primary_window": { "used_percent": 36, "reset_at": 1752700000, - "limit_window_seconds": 18000 + "limit_window_seconds": 604800 }, "secondary_window": { "used_percent": 74.5, "reset_at": "2026-07-20T08:00:00Z", "limit_window_seconds": 604800 } + }, + "rate_limit_reset_credits": { + "available_count": 3, + "applicable_available_count": 0 } } """#.utf8) let snapshot = try QuotaParser.parse(data: data, now: Date(timeIntervalSince1970: 1_752_600_000)) - let fiveHour = try #require(snapshot.fiveHour) - let weekly = try #require(snapshot.weekly) + let primary = try #require(snapshot.primaryWeekly) + let secondary = try #require(snapshot.secondaryWeekly) #expect(snapshot.plan == "Plus") - #expect(abs(fiveHour.remainingPercent - 64) < 0.001) - #expect(fiveHour.windowSeconds == 18_000) - #expect(abs(weekly.remainingPercent - 25.5) < 0.001) - #expect(weekly.resetsAt == ISO8601DateFormatter().date(from: "2026-07-20T08:00:00Z")) + #expect(abs(primary.remainingPercent - 64) < 0.001) + #expect(primary.windowSeconds == 604_800) + #expect(abs(secondary.remainingPercent - 25.5) < 0.001) + #expect(secondary.resetsAt == ISO8601DateFormatter().date(from: "2026-07-20T08:00:00Z")) + #expect(snapshot.constrainedWeekly?.remainingPercent == 25.5) + #expect(snapshot.resetCreditsAvailable == 3) + #expect(snapshot.resetCreditsApplicable == 0) +} + +@Test func resetCreditsStayUnknownWhenMissingAndClampNegativeCounts() throws { + let missingData = Data(#""" + { + "rate_limit": { + "primary_window": { "remaining_percent": 80, "limit_window_seconds": 604800 } + } + } + """#.utf8) + let clampedData = Data(#""" + { + "rate_limit": { + "primary_window": { "remaining_percent": 80, "limit_window_seconds": 604800 } + }, + "rateLimitResetCredits": { + "availableCount": -2, + "applicableAvailableCount": -1 + } + } + """#.utf8) + + let missing = try QuotaParser.parse(data: missingData) + let clamped = try QuotaParser.parse(data: clampedData) + + #expect(missing.resetCreditsAvailable == nil) + #expect(missing.resetCreditsApplicable == nil) + #expect(clamped.resetCreditsAvailable == 0) + #expect(clamped.resetCreditsApplicable == 0) } @Test func parsesRatioFieldsAndFindsWindowsArray() throws { @@ -38,8 +74,8 @@ import Testing "plan": "team", "rateLimit": { "windows": [ - { "name": "5h", "remaining_ratio": 0.42, "window_seconds": 18000 }, - { "name": "weekly", "utilization": 0.125, "window_seconds": 604800 } + { "name": "primary", "remaining_ratio": 0.42, "window_seconds": 604800 }, + { "name": "secondary", "utilization": 0.125, "window_seconds": 604800 } ] } } @@ -48,15 +84,15 @@ import Testing let snapshot = try QuotaParser.parse(data: data) #expect(snapshot.plan == "Team") - #expect(abs((snapshot.fiveHour?.remainingPercent ?? -1) - 42) < 0.001) - #expect(abs((snapshot.weekly?.remainingPercent ?? -1) - 87.5) < 0.001) + #expect(abs((snapshot.primaryWeekly?.remainingPercent ?? -1) - 42) < 0.001) + #expect(abs((snapshot.secondaryWeekly?.remainingPercent ?? -1) - 87.5) < 0.001) } @Test func clampsOutOfRangeValues() throws { let data = Data(#""" { "rate_limit": { - "primary_window": { "remaining_percent": 140, "limit_window_seconds": 18000 }, + "primary_window": { "remaining_percent": 140, "limit_window_seconds": 604800 }, "secondary_window": { "used_percent": 250, "limit_window_seconds": 604800 } } } @@ -64,8 +100,8 @@ import Testing let snapshot = try QuotaParser.parse(data: data) - #expect(snapshot.fiveHour?.remainingPercent == 100) - #expect(snapshot.weekly?.remainingPercent == 0) + #expect(snapshot.primaryWeekly?.remainingPercent == 100) + #expect(snapshot.secondaryWeekly?.remainingPercent == 0) } @Test func throwsWhenNoQuotaWindowsCanBeParsed() { diff --git a/Tests/CodexMochiCoreTests/StatusTitleFormatterTests.swift b/Tests/CodexMochiCoreTests/StatusTitleFormatterTests.swift index 5652d9a..41b083d 100644 --- a/Tests/CodexMochiCoreTests/StatusTitleFormatterTests.swift +++ b/Tests/CodexMochiCoreTests/StatusTitleFormatterTests.swift @@ -2,22 +2,22 @@ import Foundation import Testing @testable import CodexMochiCore -@Test func statusTitleUsesRoundedFiveHourRemaining() { +@Test func statusTitleUsesMostConstrainedWeeklyWindow() { let snapshot = QuotaSnapshot( plan: nil, - fiveHour: QuotaWindow(remainingPercent: 64.4, resetsAt: nil, windowSeconds: 18_000), - weekly: QuotaWindow(remainingPercent: 20, resetsAt: nil, windowSeconds: 604_800), + primaryWeekly: QuotaWindow(remainingPercent: 64.4, resetsAt: nil, windowSeconds: 604_800), + secondaryWeekly: QuotaWindow(remainingPercent: 20, resetsAt: nil, windowSeconds: 604_800), fetchedAt: Date() ) - #expect(StatusTitleFormatter.title(snapshot: snapshot, error: nil) == "64%") + #expect(StatusTitleFormatter.title(snapshot: snapshot, error: nil) == "20%") } -@Test func statusTitleFallsBackToWeeklyAndHonestStateSymbols() { +@Test func statusTitleSupportsSingleWeeklyWindowAndHonestStateSymbols() { let weeklyOnly = QuotaSnapshot( plan: nil, - fiveHour: nil, - weekly: QuotaWindow(remainingPercent: 25.5, resetsAt: nil, windowSeconds: 604_800), + primaryWeekly: QuotaWindow(remainingPercent: 25.5, resetsAt: nil, windowSeconds: 604_800), + secondaryWeekly: nil, fetchedAt: Date() ) diff --git a/Tests/CodexMochiCoreTests/UsagePaceTests.swift b/Tests/CodexMochiCoreTests/UsagePaceTests.swift new file mode 100644 index 0000000..4055fc4 --- /dev/null +++ b/Tests/CodexMochiCoreTests/UsagePaceTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import CodexMochiCore + +@Test func calculatesHourlyBurnRateAndUsageFrequency() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let samples = [ + UsageSample(date: now.addingTimeInterval(-3_600), remainingPercent: 80), + UsageSample(date: now.addingTimeInterval(-1_800), remainingPercent: 78), + UsageSample(date: now, remainingPercent: 76), + ] + + let pace = UsagePaceCalculator.calculate(samples: samples, now: now) + + #expect(abs(try #require(pace.burnRatePerHour) - 4) < 0.001) + #expect(pace.activeIntervals == 2) + #expect(pace.observedIntervals == 2) + #expect(pace.frequency == .occasional) + #expect(pace.mood == .gobbling) + #expect(abs(try #require(pace.projectedHoursRemaining) - 19) < 0.001) + #expect(pace.funPhrase == "糯米猫正在狼吞虎咽") +} + +@Test func reportsQuietFrequencyWhenQuotaDoesNotMove() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pace = UsagePaceCalculator.calculate(samples: [ + UsageSample(date: now.addingTimeInterval(-600), remainingPercent: 88), + UsageSample(date: now, remainingPercent: 88), + ], now: now) + + #expect(try #require(pace.burnRatePerHour) == 0) + #expect(pace.frequency == .quiet) + #expect(pace.mood == .resting) + #expect(pace.projectedHoursRemaining == nil) +} + +@Test func startsFreshAfterWeeklyQuotaReset() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pace = UsagePaceCalculator.calculate(samples: [ + UsageSample(date: now.addingTimeInterval(-3_600), remainingPercent: 10), + UsageSample(date: now.addingTimeInterval(-1_800), remainingPercent: 100), + UsageSample(date: now, remainingPercent: 99), + ], now: now) + + #expect(abs(try #require(pace.burnRatePerHour) - 2) < 0.001) + #expect(pace.activeIntervals == 1) + #expect(pace.observedIntervals == 1) +} + +@Test func needsTwoSamplesBeforeClaimingAPace() { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let pace = UsagePaceCalculator.calculate( + samples: [UsageSample(date: now, remainingPercent: 70)], + now: now + ) + + #expect(pace.burnRatePerHour == nil) + #expect(pace.frequency == .learning) + #expect(pace.mood == .learning) + #expect(pace.funPhrase == "糯米猫正在闻味道") +} + +@Test func persistsAndLoadsUsageSamples() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexMochiTests-\(UUID().uuidString)", isDirectory: true) + let url = directory.appendingPathComponent("history.json") + defer { try? FileManager.default.removeItem(at: directory) } + let repository = UsageHistoryRepository(fileURL: url) + let samples = [UsageSample(date: Date(timeIntervalSince1970: 123), remainingPercent: 45.5)] + + try repository.save(samples) + + #expect(try repository.load() == samples) +} diff --git a/docs/superpowers/plans/2026-07-17-compact-popover-membership.md b/docs/superpowers/plans/2026-07-17-compact-popover-membership.md new file mode 100644 index 0000000..3701d01 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-compact-popover-membership.md @@ -0,0 +1,203 @@ +# Compact Popover and Membership Badge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `348 × 500` Codex Mochi popover without the weekly pace card and add a persistent, manually correctable membership badge. + +**Architecture:** A new core membership presentation type converts the existing plan string into an honest tier and resolves an optional local override. SwiftUI uses `@AppStorage` for the override, compacts the existing single-column hierarchy, merges status and actions into one footer, and keeps pace calculations internal for cat and menu-bar motion. + +**Tech Stack:** Swift 6, SwiftUI, AppKit, macOS 13+, Swift Testing + +## Global Constraints + +- Fix both the popover controller and root view to `348 × 500`. +- Remove `PaceCard` from the UI while preserving `UsagePace` sampling and animation inputs. +- Preserve the quota card, reset-card data, local Token data, theme menu, clickable cat, popping kibble, refresh, exit, and error states. +- Treat raw `pro` as generic Pro; never guess Pro 5x or Pro 20x. +- Persist only the display override in `UserDefaults`; add no network request and save no credential or account identifier. +- Use the approved tier symbols and semantic colors. +- Keep changes local; do not commit or push. + +--- + +### Task 1: Membership Tier Model + +**Files:** +- Create: `Sources/CodexMochiCore/MembershipPresentation.swift` +- Create: `Tests/CodexMochiCoreTests/MembershipPresentationTests.swift` + +**Interfaces:** +- Consumes: the existing display plan string from `QuotaSnapshot.plan` +- Produces: `MembershipTier.detect(plan:)`, `MembershipOverride`, and `MembershipTier.resolved(detected:override:)` + +- [ ] **Step 1: Write failing detection and override tests** + +```swift +@Test func detectsMembershipWithoutGuessingGenericPro() { + #expect(MembershipTier.detect(plan: "Plus") == .plus) + #expect(MembershipTier.detect(plan: "pro") == .pro) + #expect(MembershipTier.detect(plan: "pro-5x") == .pro5x) + #expect(MembershipTier.detect(plan: "PRO 20X") == .pro20x) + #expect(MembershipTier.detect(plan: nil) == .member) +} + +@Test func manualMembershipOverrideWinsUntilAutomaticIsRestored() { + #expect(MembershipTier.resolved(detected: .pro, override: .automatic) == .pro) + #expect(MembershipTier.resolved(detected: .pro, override: .pro20x) == .pro20x) + #expect(MembershipTier.resolved(detected: .plus, override: .pro5x) == .pro5x) +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `swift test --filter MembershipPresentationTests` + +Expected: compilation failure because `MembershipTier` and `MembershipOverride` do not exist. + +- [ ] **Step 3: Implement the membership types** + +```swift +public enum MembershipTier: Equatable, Sendable { + case plus, pro, pro5x, pro20x, member + + public static func detect(plan: String?) -> MembershipTier { + guard let plan else { return .member } + let normalized = plan.lowercased().filter { $0.isLetter || $0.isNumber } + if normalized.contains("pro20x") { return .pro20x } + if normalized.contains("pro5x") { return .pro5x } + if normalized == "pro" { return .pro } + if normalized.contains("plus") { return .plus } + return .member + } + + public static func resolved( + detected: MembershipTier, + override: MembershipOverride + ) -> MembershipTier { + switch override { + case .automatic: detected + case .plus: .plus + case .pro5x: .pro5x + case .pro20x: .pro20x + } + } +} + +public enum MembershipOverride: String, CaseIterable, Equatable, Sendable { + case automatic, plus, pro5x, pro20x +} +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `swift test --filter MembershipPresentationTests` + +Expected: both membership tests pass. + +### Task 2: Compact Frontend + +**Files:** +- Modify: `Sources/CodexMochiApp/PopoverView.swift` +- Modify: `Sources/CodexMochiApp/StatusBarController.swift` + +**Interfaces:** +- Consumes: `MembershipTier`, `MembershipOverride`, `QuotaSnapshot.plan` +- Produces: `MembershipBadge`, a `348 × 500` popover, and compact card/footer layout + +- [ ] **Step 1: Add persisted membership resolution to `PopoverView`** + +```swift +@AppStorage("membershipOverride") private var storedMembershipOverride = MembershipOverride.automatic.rawValue + +private var membershipOverride: MembershipOverride { + MembershipOverride(rawValue: storedMembershipOverride) ?? .automatic +} + +private var membership: MembershipTier { + MembershipTier.resolved( + detected: .detect(plan: store.snapshot?.plan), + override: membershipOverride + ) +} +``` + +- [ ] **Step 2: Add the compact membership menu** + +Build `MembershipBadge` as a borderless `Menu`. Its label uses `PLUS`, `PRO`, `PRO 5×`, `PRO 20×`, or `MEMBER`; its system image uses `plus`, `diamond.fill`, `bolt.fill`, `crown.fill`, or `person.crop.circle`; and its menu writes one of the four stable override raw values back to `@AppStorage`. + +- [ ] **Step 3: Remove the weekly pace presentation** + +Delete the `PaceCard` call and the private `PaceCard` and `PaceMetric` view types. Keep all `store.pace` uses in the header cat, caption, and status-bar renderer. + +- [ ] **Step 4: Apply the approved compact metrics** + +```swift +header + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 10) + +VStack(spacing: 8) { + QuotaCard( + eyebrow: "本周额度", + window: store.snapshot?.primaryWeekly, + fallback: stateFallback + ) + ResetCreditCard( + available: store.snapshot?.resetCreditsAvailable, + applicable: store.snapshot?.resetCreditsApplicable, + fallback: stateFallback, + theme: theme + ) + LocalTokenCard(snapshot: store.localTokens, theme: theme) +} +.padding(.horizontal, 12) + +compactFooter + .padding(.horizontal, 14) + .frame(height: 42) + +.frame(width: 348, height: 500, alignment: .top) +``` + +Set the cat to `58 × 58`, headline to 21 points, card corner radius to 12, card padding to 10–11, and secondary copy to the ordinary system font. Merge the status dot/text, refresh, and exit into `compactFooter` while preserving a two-line error allowance. + +- [ ] **Step 5: Set AppKit popover size and compile** + +Change `popover.contentSize` to `NSSize(width: 348, height: 500)` and run `swift build`. + +Expected: build exits 0; `rg -n "PaceCard|PaceMetric" Sources/CodexMochiApp` returns no matches. + +### Task 3: Frontend Critique, Documentation, and Local Delivery + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Consumes: completed compact UI +- Produces: accurate documentation and a verified local `.app` + +- [ ] **Step 1: Perform the frontend-design critique** + +Check the implementation against the approved design: only cat plus popping kibble act as signature elements; all other surfaces are quiet; Plus, Pro 5x, and Pro 20x are distinguishable without relying on color alone; the three-card hierarchy stays legible at 348 points; and normal status text fits one line. Remove any decoration or spacing that does not encode information. + +- [ ] **Step 2: Update README features and detection boundary** + +Document the compact `348 × 500` popover, membership badge, generic-Pro honesty, and local manual Pro 5x/Pro 20x override. + +- [ ] **Step 3: Run complete verification** + +```bash +swift test +./scripts/build_app.sh +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +git diff --check +rg -n "contentSize = NSSize\(width: 348, height: 500\)|frame\(width: 348, height: 500" Sources/CodexMochiApp +``` + +Expected: all tests pass, release packaging succeeds, signing and plist checks exit 0, diff check is clean, and both size declarations are present. + +- [ ] **Step 4: Replace and verify the running local app** + +Terminate only the running `CodexMochi` process, open `dist/CodexMochi.app`, wait three seconds, and require `pgrep -fl CodexMochi` to point at this workspace. Confirm `git branch --show-current` remains `codex/weekly-rate` and no commit or push occurred. diff --git a/docs/superpowers/plans/2026-07-17-footer-action-buttons.md b/docs/superpowers/plans/2026-07-17-footer-action-buttons.md new file mode 100644 index 0000000..339b62e --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-footer-action-buttons.md @@ -0,0 +1,66 @@ +# Footer Action Buttons Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the refresh and exit actions visually recognizable as buttons without changing the compact footer layout. + +**Architecture:** Add one private reusable SwiftUI `ButtonStyle` in `PopoverView.swift`. Apply it to the existing refresh and exit buttons with theme-accent and neutral tints while preserving their actions and states. + +**Tech Stack:** Swift 6, SwiftUI, AppKit, macOS 13+ + +## Global Constraints + +- Keep the footer at exactly `42pt` and the popover at exactly `348 × 404`. +- Preserve all existing actions, labels, cards, animation, and data behavior. +- Use independent `24pt` high rounded button frames with `7pt` corners and `8pt` horizontal padding. +- Do not commit or push. + +--- + +### Task 1: Add Framed Footer Actions + +**Files:** +- Modify: `Sources/CodexMochiApp/PopoverView.swift` + +**Interfaces:** +- Consumes: the existing refresh and exit `Button` labels plus `MochiPalette.accent(for:)` and `MochiPalette.secondaryInk` +- Produces: `FooterActionButtonStyle` and two visibly framed footer actions + +- [x] **Step 1: Verify the new style is absent** + +Run: + +```bash +! rg -n 'FooterActionButtonStyle' Sources/CodexMochiApp/PopoverView.swift +``` + +Expected: the command exits successfully because the required style is not implemented yet; the positive acceptance assertion `rg -n 'FooterActionButtonStyle' ...` fails. + +- [x] **Step 2: Implement and apply the button style** + +Add a private `FooterActionButtonStyle: ButtonStyle` that gives its label `8pt` horizontal padding, a fixed `24pt` height, a continuous rounded rectangle with `7pt` corners, an 8% tint fill, and a 26% tint stroke. Increase fill and stroke opacity while pressed. + +Apply the style to refresh with `MochiPalette.accent(for: theme)` and to exit with `MochiPalette.secondaryInk`. Keep the refresh task, disabled state, exit termination action, icon, and labels unchanged. + +- [x] **Step 3: Verify the source criteria** + +Run: + +```bash +rg -n 'FooterActionButtonStyle|frame\(height: 24\)|cornerRadius: 7' Sources/CodexMochiApp/PopoverView.swift +rg -n 'frame\(height: 42\)|height: 404' Sources/CodexMochiApp/PopoverView.swift Sources/CodexMochiApp/StatusBarController.swift +``` + +Expected: the reusable style and both unchanged layout heights are present. + +- [x] **Step 4: Run complete verification and replace the app** + +```bash +swift test +./scripts/build_app.sh +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +git diff --check +``` + +Expected: 33 tests pass; build, signing, plist, and whitespace checks succeed. Restart the exact `dist/CodexMochi.app` process and verify its launch time is newer than the rebuilt executable. diff --git a/docs/superpowers/plans/2026-07-17-interactive-mochi-theme-reset-card.md b/docs/superpowers/plans/2026-07-17-interactive-mochi-theme-reset-card.md new file mode 100644 index 0000000..4d37c78 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-interactive-mochi-theme-reset-card.md @@ -0,0 +1,146 @@ +# Interactive Mochi Theme and Reset Card Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add clickable cat reactions, eight persistent soft themes, a reset-credit card, and a Token-speed-driven animated food bowl to the existing CodexMochi popover. + +**Architecture:** Extend the existing quota snapshot/parser with optional reset-credit counts from the same usage response. Put deterministic theme, cat-reaction, and bowl-motion mappings in a small tested core model; SwiftUI owns transient animation state and persists only the selected theme ID through `AppStorage`. + +**Tech Stack:** Swift 6, Swift Testing, Foundation, SwiftUI, AppKit, macOS 13+ + +## Global Constraints + +- Keep “本周额度 · 主窗口” and replace only the secondary card. +- Reset credits are display-only; no redemption request or account mutation. +- Theme selection persists locally and uses eight named soft colors. +- Quota warning semantics remain green/yellow/red and do not follow the theme. +- Respect Reduce Motion by stopping continuous bowl rotation. +- Keep all changes local and uncommitted; do not push GitHub. + +--- + +### Task 1: Parse and model reset credits + +**Files:** +- Modify: `Sources/CodexMochiCore/QuotaModels.swift` +- Modify: `Sources/CodexMochiCore/QuotaParser.swift` +- Modify: `Tests/CodexMochiCoreTests/QuotaParserTests.swift` +- Modify: `Tests/CodexMochiCoreTests/QuotaClientTests.swift` +- Modify: `Tests/CodexMochiCoreTests/StatusTitleFormatterTests.swift` + +**Interfaces:** +- Produces: `QuotaSnapshot.resetCreditsAvailable: Int?` and `QuotaSnapshot.resetCreditsApplicable: Int?`. +- Existing snapshot initializers gain both optional fields with default `nil` so unrelated callers remain concise. + +- [ ] **Step 1: Add failing parser tests** + +Add fixtures asserting: + +```swift +"rate_limit_reset_credits": { + "available_count": 3, + "applicable_available_count": 0 +} +``` + +parses as `3` and `0`; a response without the object yields `nil` and `nil`; a partial object preserves the present field without inventing the missing one. + +- [ ] **Step 2: Verify RED** + +Run: `swift test --filter parsesPrimaryAndSecondaryAsWeeklyWindows` + +Expected: compile failure because reset-credit snapshot properties do not exist. + +- [ ] **Step 3: Add optional model fields and parser logic** + +Read `rate_limit_reset_credits` or `rateLimitResetCredits`; use the existing numeric helper for `available_count`/`availableCount` and `applicable_available_count`/`applicableAvailableCount`. Clamp negative values to zero but keep absent fields `nil`. + +- [ ] **Step 4: Update existing snapshot construction and run tests** + +Run: `swift test` + +Expected: all existing and reset-credit tests pass. + +### Task 2: Theme, reaction, and food-bowl motion models + +**Files:** +- Create: `Sources/CodexMochiCore/MochiPresentation.swift` +- Create: `Tests/CodexMochiCoreTests/MochiPresentationTests.swift` + +**Interfaces:** +- Produces: `MochiThemeID`, `CatReaction`, and `FoodBowlMotion`. +- `MochiThemeID.init(storedValue:)` falls back to `.mint`; `CatReaction.next` cycles through five reactions; `FoodBowlMotion.init(mood:)` maps `TokenSpeedMood` to motion level and duration. + +- [ ] **Step 1: Write failing presentation tests** + +Cover: + +```swift +@Test func exposesEightPersistentSoftThemes() +@Test func unknownStoredThemeFallsBackToMint() +@Test func catReactionsCycleInTheApprovedOrder() +@Test func mapsTokenSpeedToIncreasingFoodBowlMotion() +``` + +Assert the exact reaction order `.hearts -> .stars -> .blush -> .grin -> .tongue -> .hearts` and that flying duration is shorter than gobbling, which is shorter than munching. + +- [ ] **Step 2: Verify RED** + +Run: `swift test --filter MochiPresentationTests` + +Expected: compile failure because the new presentation types do not exist. + +- [ ] **Step 3: Implement deterministic presentation models** + +Define eight stable raw string theme IDs and Chinese display names. Define reaction glyph/particle metadata without importing SwiftUI. Define bowl motion cases `still`, `sway`, `slowSpin`, `fastSpin`, `flying` with rotation durations `nil`, `2.4`, `1.4`, `0.65`, and `0.28` seconds. + +- [ ] **Step 4: Run focused and full tests** + +Run: `swift test --filter MochiPresentationTests && swift test` + +Expected: all tests pass. + +### Task 3: Interactive SwiftUI, theme menu, food bowl, and local replacement + +**Files:** +- Modify: `Sources/CodexMochiApp/PopoverView.swift` +- Modify: `Sources/CodexMochiApp/StatusBarController.swift` +- Modify: `README.md` + +**Interfaces:** +- Consumes: reset-credit snapshot fields, `MochiThemeID`, `CatReaction`, `FoodBowlMotion`, and existing `TokenSpeedMood`. +- Produces: clickable `InteractiveMochiFace`, `ThemePickerButton`, `ResetCreditCard`, and `SpinningFoodBowl` SwiftUI components. + +- [ ] **Step 1: Add persistent theme selection** + +Use `@AppStorage("mochiTheme") private var storedTheme = MochiThemeID.mint.rawValue`. Derive the validated theme and pass its accent colors to header, cat, reset card, Token card, and bowl. Render a small palette `Menu` beside the cat with eight color circles and checkmarks. + +- [ ] **Step 2: Make the cat interactive** + +Wrap `MochiFace` in a plain button. Store the current optional `CatReaction`, reaction index, bounce flag, and a generation counter. On click, advance the reaction, animate a spring bounce, show glyph particles, and schedule a 1.5-second reset that only executes if its generation is still current. When `hasError` is true, render the error eyes instead of reaction eyes. + +- [ ] **Step 3: Replace the secondary quota card** + +Render a soft-themed reset-card component with a ticket icon, large `N 张`, “储备重置卡” label, and `当前可使用 N 张`. If the count is absent, show an em dash and “暂未读到重置卡”. Do not include a redemption control. + +- [ ] **Step 4: Add the animated food bowl to Token card** + +Reserve a 64-point trailing column in `LocalTokenCard`. Draw a bowl, paw mark, six kibble dots, and conditional crumbs/wind lines using SwiftUI shapes. Use `FoodBowlMotion` duration for repeat-forever rotation; when `accessibilityReduceMotion` is true, keep rotation at zero and show only static particles appropriate to the mood. + +- [ ] **Step 5: Fit and document the UI** + +Keep the 348-point width and 690-point popover height. Compress Token text spacing if required rather than increasing height. Document interactions, eight themes, reset-card source, and Reduce Motion behavior in `README.md`. + +- [ ] **Step 6: Verify and replace the local app** + +Run: + +```bash +swift test +./scripts/build_app.sh +git diff --check +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +``` + +Then terminate only `CodexMochi`, launch the rebuilt app, wait five seconds, and verify its path and process health. Confirm `origin/main...HEAD` remains `0 0` and all work is still uncommitted. diff --git a/docs/superpowers/plans/2026-07-17-local-token-pace.md b/docs/superpowers/plans/2026-07-17-local-token-pace.md new file mode 100644 index 0000000..b92239d --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-local-token-pace.md @@ -0,0 +1,164 @@ +# CodexMochi Local Token Pace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show today's raw Codex Token usage on this Mac and a cute recent Token-speed state in the existing menu bar popover. + +**Architecture:** Add a focused `LocalTokenUsage` core unit that parses persisted Codex JSONL `token_count` events, aggregates positive cumulative deltas for the local calendar day, and calculates a 15-minute hourly rate. `QuotaStore` refreshes this snapshot alongside quota data, while SwiftUI renders a separate Token card so raw Token rate is never confused with official quota percentage. + +**Tech Stack:** Swift 6, Foundation JSON decoding, Swift Testing, SwiftUI, macOS 13+ + +## Global Constraints + +- Read only from `~/.codex/sessions`; never modify or upload Codex logs. +- “Today” starts at local-calendar midnight in the user's current time zone. +- Raw Token counts must be labeled as local raw Token, not official quota Token. +- Missing directories, unreadable files, malformed JSONL lines, and empty data produce an honest empty state. +- Keep all work local and uncommitted; do not push GitHub. + +--- + +### Task 1: Local Token log aggregation and pace model + +**Files:** +- Create: `Sources/CodexMochiCore/LocalTokenUsage.swift` +- Create: `Tests/CodexMochiCoreTests/LocalTokenUsageTests.swift` + +**Interfaces:** +- Produces: `TokenCounts`, `LocalTokenEvent`, `LocalTokenSnapshot`, `TokenSpeedMood`, `LocalTokenUsageCalculator.calculate(events:now:calendar:recentWindow:)`, and `LocalTokenLogReader.read(now:calendar:)`. +- `LocalTokenSnapshot` exposes today's category totals, `recentTokensPerHour`, `speedMood`, and `lastUpdatedAt` for the UI and store. + +- [ ] **Step 1: Write failing aggregation tests** + +Cover these concrete cases using in-memory `LocalTokenEvent` values: + +```swift +@Test func aggregatesPositiveCumulativeDeltasWithoutCountingDuplicates() +@Test func keepsOnlyEventsFromTheLocalCalendarDay() +@Test func calculatesRecentFifteenMinuteHourlyRate() +@Test func classifiesEveryCuteTokenSpeedBoundary() +``` + +Use events with cumulative totals `100 -> 160 -> 160 -> 25` and assert the daily total is `185`: the first session contributes 160 once and the cumulative reset contributes 25 without subtracting. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `swift test --filter LocalTokenUsageTests` + +Expected: compilation fails because `LocalTokenEvent` and `LocalTokenUsageCalculator` do not exist. + +- [ ] **Step 3: Implement the minimal aggregation model** + +Implement: + +```swift +public struct TokenCounts: Codable, Equatable, Sendable { + public let input: Int64 + public let cachedInput: Int64 + public let output: Int64 + public let reasoningOutput: Int64 + public let total: Int64 +} + +public struct LocalTokenEvent: Equatable, Sendable { + public let sessionID: String + public let date: Date + public let cumulative: TokenCounts +} + +public enum TokenSpeedMood: Equatable, Sendable { + case learning, resting, nibbling, munching, gobbling, flying +} +``` + +Group events by `sessionID`, sort by time, and add the first cumulative value plus later positive deltas. When a cumulative category decreases, treat the new value as a reset and add it. Calculate the 15-minute rate as recent positive total deltas divided by the observed fraction of an hour, capped to the configured recent window. + +- [ ] **Step 4: Implement the JSONL reader** + +Scan the current local day directory plus the previous day's directory to support sessions crossing midnight. Decode only lines where: + +```text +type == event_msg +payload.type == token_count +payload.info.total_token_usage exists +``` + +Use the rollout filename as `sessionID`, skip malformed lines, and return `[]` when the root is missing or unreadable. + +- [ ] **Step 5: Run focused and full tests** + +Run: `swift test --filter LocalTokenUsageTests && swift test` + +Expected: all new tests and the existing 17 tests pass with zero failures. + +### Task 2: Store refresh, Token card, and local replacement + +**Files:** +- Modify: `Sources/CodexMochiCore/QuotaStore.swift` +- Modify: `Sources/CodexMochiApp/PopoverView.swift` +- Modify: `Sources/CodexMochiApp/StatusBarController.swift` +- Modify: `Sources/CodexMochiApp/MochiIconRenderer.swift` +- Modify: `README.md` +- Test: `Tests/CodexMochiCoreTests/QuotaClientTests.swift` + +**Interfaces:** +- Consumes: `LocalTokenLogReader.read(now:calendar:)` and `LocalTokenUsageCalculator.calculate(...)` from Task 1. +- Produces: `QuotaStore.localTokens`, refreshed at initialization and on each quota refresh. + +- [ ] **Step 1: Write a failing store test** + +Add a test-only injected `tokenSnapshotProvider` to the desired initializer API and assert `QuotaStore.refresh()` publishes the supplied `LocalTokenSnapshot` even when quota fetching succeeds independently. + +- [ ] **Step 2: Run the store test and verify RED** + +Run: `swift test --filter quotaStoreRefreshesLocalTokenSnapshot` + +Expected: compilation fails because the initializer and `localTokens` property do not exist. + +- [ ] **Step 3: Add local Token refresh to `QuotaStore`** + +Add: + +```swift +@Published public private(set) var localTokens: LocalTokenSnapshot +private let tokenSnapshotProvider: @Sendable () -> LocalTokenSnapshot +``` + +Refresh local Token data before the remote quota request so local stats still update when the network request fails. Keep the existing initializer defaults usable by production and existing tests. + +- [ ] **Step 4: Add the Token card and cute speed copy** + +Insert `LocalTokenCard(snapshot:)` between quota cards and `PaceCard`. Show: + +- `今日 Mac 口粮` +- compact total such as `2,890 万` +- `最近 15 分钟` rate such as `1,584 万 / 时` +- input/cache/output compact breakdown +- mood copy from `TokenSpeedMood` +- footnote `本机原始 Token · 不等于额度 Token` + +Use a pale sky-blue card distinct from the mint quota-pace card. Increase popover height only as much as required and retain the current 348-point width. + +- [ ] **Step 5: Connect motion without overriding quota warnings** + +Expose `TokenSpeedMood.animationUrgency`. For the menu bar and popover mascot, use the maximum of quota pace urgency and Token speed urgency. Token activity may speed up the cat, while quota remaining color still communicates risk. + +- [ ] **Step 6: Update documentation** + +Add local raw Token totals, 15-minute Token speed, privacy boundary, and the limitation that other devices or unpersisted sessions may be absent. + +- [ ] **Step 7: Verify and replace the local app** + +Run: + +```bash +swift test +./scripts/build_app.sh +git diff --check +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +``` + +Expected: all tests pass, release build succeeds, diff check is clean, signature is valid, and the plist is valid. + +Terminate only the running CodexMochi process, launch `dist/CodexMochi.app`, wait five seconds, and verify the new process path. Confirm `git status` still shows local uncommitted changes and no commit or push occurred. diff --git a/docs/superpowers/plans/2026-07-17-popping-kibble-burst.md b/docs/superpowers/plans/2026-07-17-popping-kibble-burst.md new file mode 100644 index 0000000..75a2fcd --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-popping-kibble-burst.md @@ -0,0 +1,177 @@ +# Popping Kibble Burst Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the rotating food bowl with speed-sensitive kibble and spark particles that burst outward and fade. + +**Architecture:** `CodexMochiCore` owns a deterministic `FoodBurstStyle` mapping from `TokenSpeedMood` to particle counts, timing, and travel strength. SwiftUI owns rendering: a stable bowl plus deterministic particle slots animated by `TimelineView`, with all movement disabled when Reduce Motion is active. + +**Tech Stack:** Swift 6, SwiftUI, macOS 13+, Swift Testing + +## Global Constraints + +- Keep the bowl stable; no rotation or shaking. +- Kibble flies outward and fades instead of falling back into the bowl. +- `gobbling` adds short sparks; `flying` adds denser kibble and long outward sparks. +- Use deterministic particle slots to avoid per-frame random flicker. +- Reduce Motion renders a static bowl with no moving particles. +- Preserve themes, reset cards, quota cards, and clickable cat behavior. +- Keep all changes local: do not commit or push. + +--- + +### Task 1: Testable Burst Style + +**Files:** +- Modify: `Sources/CodexMochiCore/MochiPresentation.swift` +- Modify: `Tests/CodexMochiCoreTests/MochiPresentationTests.swift` + +**Interfaces:** +- Consumes: `TokenSpeedMood` +- Produces: `FoodBurstStyle.init(mood:)`, `kibbleCount`, `sparkCount`, `cycleDuration`, `travelStrength` + +- [ ] **Step 1: Replace the rotation assertions with failing burst-style assertions** + +```swift +#expect(FoodBurstStyle(mood: .learning) == .still) +#expect(FoodBurstStyle(mood: .nibbling).kibbleCount == 1) +#expect(FoodBurstStyle(mood: .munching).kibbleCount == 3) +#expect(FoodBurstStyle(mood: .gobbling).sparkCount > 0) +#expect(FoodBurstStyle(mood: .flying).sparkCount > FoodBurstStyle(mood: .gobbling).sparkCount) +#expect(FoodBurstStyle(mood: .flying).travelStrength > FoodBurstStyle(mood: .gobbling).travelStrength) +``` + +- [ ] **Step 2: Run the focused test and verify it fails because `FoodBurstStyle` does not exist** + +Run: `swift test --filter MochiPresentationTests` + +Expected: compilation failure naming the missing `FoodBurstStyle` type. + +- [ ] **Step 3: Implement the minimal burst-style mapping** + +```swift +public enum FoodBurstStyle: Equatable, Sendable { + case still, tiny, popping, sparkling, firework + + public init(mood: TokenSpeedMood) { + switch mood { + case .learning, .resting: self = .still + case .nibbling: self = .tiny + case .munching: self = .popping + case .gobbling: self = .sparkling + case .flying: self = .firework + } + } + + public var kibbleCount: Int { + switch self { + case .still: 0 + case .tiny: 1 + case .popping: 3 + case .sparkling: 5 + case .firework: 8 + } + } + + public var sparkCount: Int { + switch self { + case .still, .tiny, .popping: 0 + case .sparkling: 3 + case .firework: 7 + } + } + + public var cycleDuration: Double? { + switch self { + case .still: nil + case .tiny: 1.8 + case .popping: 1.25 + case .sparkling: 0.8 + case .firework: 0.48 + } + } + + public var travelStrength: Double { + switch self { + case .still: 0 + case .tiny: 0.45 + case .popping: 0.7 + case .sparkling: 0.95 + case .firework: 1.35 + } + } +} +``` + +- [ ] **Step 4: Run the focused test and require all burst assertions to pass** + +Run: `swift test --filter MochiPresentationTests` + +Expected: all `MochiPresentationTests` pass. + +### Task 2: Stable Bowl and Outward Particles + +**Files:** +- Modify: `Sources/CodexMochiApp/PopoverView.swift` + +**Interfaces:** +- Consumes: `FoodBurstStyle`, `TokenSpeedMood`, `MochiThemeID` +- Produces: `PoppingFoodBowl`, deterministic kibble slots, deterministic spark slots + +- [ ] **Step 1: Replace `SpinningFoodBowl` with `PoppingFoodBowl` at the token card call site** + +```swift +PoppingFoodBowl(mood: snapshot.speedMood, theme: theme) + .frame(width: 56, height: 56) +``` + +- [ ] **Step 2: Render a stable `FoodBowlGraphic` and deterministic particle slots** + +Use the timeline cycle phase to calculate each slot's local progress. Kibble starts at the bowl rim, moves along a fixed outward vector with a small upward arc, scales down, and fades to zero. Sparks render only for `sparkling` and `firework` as tapered capsules aligned with their outward vectors. + +- [ ] **Step 3: Enforce Reduce Motion** + +When `accessibilityReduceMotion` is true, skip all kibble and spark views and render only `FoodBowlGraphic`. + +- [ ] **Step 4: Compile the app** + +Run: `swift build` + +Expected: build exits 0 with no references to `SpinningFoodBowl` or `FoodBowlMotion`. + +### Task 3: Documentation and Local Delivery + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Consumes: completed burst animation behavior +- Produces: accurate feature description and a verified local `.app` + +- [ ] **Step 1: Update the README animation description** + +Replace “猫粮碗逐级加速旋转” with wording that describes outward popping kibble, sparks at fast speed, and direct spark spray at very fast speed. + +- [ ] **Step 2: Run the full test and packaging gates** + +Run: + +```bash +swift test +./scripts/build_app.sh +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +git diff --check +``` + +Expected: 0 test failures, release build succeeds, signature is valid, plist is valid, and diff check exits 0. + +- [ ] **Step 3: Replace the running local process** + +Terminate only the existing `CodexMochi` executable, open `dist/CodexMochi.app`, wait three seconds, and verify `pgrep -fl CodexMochi` points to the current workspace bundle. + +- [ ] **Step 4: Confirm the work remains local** + +Run: `git status --short && git branch --show-current` + +Expected: local modifications remain on `codex/weekly-rate`; no commit or push occurs. diff --git a/docs/superpowers/plans/2026-07-17-remove-popover-blank-space.md b/docs/superpowers/plans/2026-07-17-remove-popover-blank-space.md new file mode 100644 index 0000000..f988abc --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-remove-popover-blank-space.md @@ -0,0 +1,91 @@ +# Remove Popover Blank Space Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the unused vertical area below the three information cards without changing any visible content or interaction. + +**Architecture:** Keep the existing SwiftUI hierarchy and replace the expanding spacer with a fixed six-point gap. Set the SwiftUI root and AppKit popover container to the same `348 × 404` dimensions so neither layer reintroduces blank space. + +**Tech Stack:** Swift 6, SwiftUI, AppKit, macOS 13+ + +## Global Constraints + +- Keep the width at exactly `348pt`. +- Set both view and popover heights to exactly `404pt`. +- Preserve every existing card, control, animation, style, and data path. +- Do not commit or push. + +--- + +### Task 1: Make the Popover Content-Fit + +**Files:** +- Modify: `Sources/CodexMochiApp/PopoverView.swift` +- Modify: `Sources/CodexMochiApp/StatusBarController.swift` + +**Interfaces:** +- Consumes: the existing `header`, three-card stack, and `compactFooter` +- Produces: a fixed `348 × 404` popover with a six-point card-to-footer gap + +- [x] **Step 1: Record the current source assertions** + +Run: + +```bash +rg -n 'Spacer\(minLength: 6\)|height: 500' Sources/CodexMochiApp/PopoverView.swift Sources/CodexMochiApp/StatusBarController.swift +``` + +Expected: one expanding spacer and two `500`-point height declarations are present. + +- [x] **Step 2: Apply the minimal layout change** + +In `PopoverView`, replace: + +```swift +Spacer(minLength: 6) +``` + +with: + +```swift +Color.clear.frame(height: 6) +``` + +Change the root view to: + +```swift +.frame(width: 348, height: 404, alignment: .top) +``` + +Change the AppKit container to: + +```swift +popover.contentSize = NSSize(width: 348, height: 404) +``` + +- [x] **Step 3: Verify source-level acceptance criteria** + +Run: + +```bash +rg -n 'height: 404' Sources/CodexMochiApp/PopoverView.swift Sources/CodexMochiApp/StatusBarController.swift +! rg -n 'Spacer\(minLength: 6\)|height: 500' Sources/CodexMochiApp/PopoverView.swift Sources/CodexMochiApp/StatusBarController.swift +``` + +Expected: both `404`-point declarations are found and the expanding spacer/old height are absent. + +- [x] **Step 4: Run complete verification** + +```bash +swift test +./scripts/build_app.sh +codesign --verify --deep --strict --verbose=2 dist/CodexMochi.app +plutil -lint dist/CodexMochi.app/Contents/Info.plist +git diff --check +``` + +Expected: all tests pass; packaging, signing, plist, and whitespace checks exit successfully. + +- [x] **Step 5: Replace the running local app** + +Terminate only the current `CodexMochi` process, open `dist/CodexMochi.app`, wait briefly, and verify the running executable resolves to this workspace. Leave all changes uncommitted and unpushed. diff --git a/docs/superpowers/specs/2026-07-17-compact-popover-membership-design.md b/docs/superpowers/specs/2026-07-17-compact-popover-membership-design.md new file mode 100644 index 0000000..542df05 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-compact-popover-membership-design.md @@ -0,0 +1,78 @@ +# 精简弹窗与会员徽章设计 + +## 目标 + +把点击菜单栏后出现的弹窗从 `348 × 690` 压缩为 `348 × 500`。删除“本周节奏”展示板块,其余信息和互动功能保留,并在紧凑头部加入可识别、可手动修正的会员徽章。 + +## 信息结构 + +弹窗保持单列: + +1. 紧凑头部:可点击糯米猫、产品名、会员徽章、周额度数字、状态短句、主题按钮。 +2. 主周额度卡:剩余百分比、进度条、重置时间。 +3. 重置卡:储备数量与当前可用数量。 +4. 今日 Mac 口粮:累计 Token、最近 15 分钟速度、输入/缓存/输出和爆猫粮动效。 +5. 单行底栏:更新状态、刷新、退出。 + +“本周节奏”卡片从界面完全移除,但 `UsagePace` 的采样和计算继续保留,用于糯米猫动作、头部短句和菜单栏图标。 + +## 尺寸与密度 + +- 弹窗固定为 `348 × 500`。 +- 头部小猫由 72 缩至约 58,头部上下留白压缩至约 24。 +- 主要标题由 24 缩至 21;主要数字继续使用圆体粗字和等宽数字。 +- 三张卡片继续单列,圆角缩至 12,内边距缩至 10–11,卡片间距缩至 7–8。 +- 原状态行、分割线和 52 高底栏合并为约 38 高的单行底栏。 +- 错误信息允许占两行;正常状态保持单行,不通过截断隐藏关键错误。 + +## 视觉系统 + +主题仍由现有八套柔色控制。固定语义色如下: + +- `Mint #73D6B0`:Plus 徽章与健康状态。 +- `Sky #76BCEB`:Pro 5x 闪电徽章。 +- `Butter #E4C96F`:Pro 20x 皇冠徽章。 +- `Fog #AAB6C3`:通用 Pro 与未知会员。 +- `Coral #F55E57`:临界额度与错误。 +- `Kibble #996139`:猫粮粒子。 + +圆体只用于糯米猫品牌、主要额度和会员徽章;普通系统字体用于说明和状态;数字使用等宽数字。唯一视觉记忆点是“可互动糯米猫 + 爆猫粮”,不增加额外装饰。 + +## 会员检测与手动修正 + +现有额度接口的 `plan_type` 可以可靠区分 Plus 与 Pro,但当前 Pro 20x 账户仍只返回 `pro`,不能可靠自动区分 Pro 5x 和 Pro 20x。 + +核心层新增会员类型并保留诚实边界: + +- `plus` → Plus。 +- 明确的 `pro-5x` / `pro5x` → Pro 5x。 +- 明确的 `pro-20x` / `pro20x` → Pro 20x。 +- 只有 `pro` → 通用 Pro,不猜倍数。 +- 未知值或缺失 → Member。 + +会员徽章为可点击菜单,提供“自动、Plus、Pro 5x、Pro 20x”。手动选择保存在 `UserDefaults`,当前用户选择 Pro 20x 后会持续显示,直到切回“自动”。如果未来接口返回明确的 Pro 子类型,自动模式会直接显示对应徽章。 + +徽章标志: + +- Plus:`plus`,薄荷色,文案 `PLUS`。 +- 通用 Pro:`diamond.fill`,雾蓝色,文案 `PRO`。 +- Pro 5x:`bolt.fill`,柔蓝色,文案 `PRO 5×`。 +- Pro 20x:`crown.fill`,奶油金,文案 `PRO 20×`。 +- 未知:`person.crop.circle`,雾灰色,文案 `MEMBER`。 + +## 状态与交互 + +- 小猫点击表情、主题选择和爆猫粮速度分级保持不变。 +- 会员菜单和主题菜单都支持键盘与辅助功能标签。 +- 读取失败时保留上次会员信息;首次读取失败时显示 `MEMBER`。 +- 手动会员选择只改变展示,不参与额度百分比、Token 速度或任何消耗计算。 +- 不新增网络请求,不保存令牌或账户标识。 + +## 验收标准 + +- `PaceCard` 不再出现在弹窗视图树,其他数据与交互入口仍存在。 +- `NSPopover` 与根视图尺寸均为 `348 × 500`。 +- Plus、通用 Pro、Pro 5x、Pro 20x、未知会员的检测和手动覆盖有自动化测试。 +- 当前账户可通过会员徽章选择 Pro 20x,关闭并重新打开弹窗后仍保持。 +- 完整测试、Release 构建、签名、`Info.plist` 和差异检查通过。 +- 新版本替换当前本机菜单栏进程;不提交、不推送。 diff --git a/docs/superpowers/specs/2026-07-17-footer-action-buttons-design.md b/docs/superpowers/specs/2026-07-17-footer-action-buttons-design.md new file mode 100644 index 0000000..5157787 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-footer-action-buttons-design.md @@ -0,0 +1,22 @@ +# 底栏操作按钮框设计 + +## 目标 + +让弹窗右下角的“刷新”和“退出”一眼可识别为可点击操作,同时保持现有紧凑尺寸、布局、文案和所有其他元素不变。 + +## 设计 + +- 两个操作继续位于底栏右侧,顺序保持“刷新、退出”。 +- 每个操作使用独立的 `24pt` 高柔色圆角框,圆角 `7pt`,水平内边距 `8pt`。 +- “刷新”沿用当前主题强调色,使用低透明度底色与细边线;“退出”使用现有次要文字色的低透明度底色与细边线。 +- 保留刷新图标、刷新中状态、禁用行为和退出文案。 +- 按下时只略微加深底色和边线,不新增持续动画。 +- 底栏高度继续为 `42pt`,弹窗继续为 `348 × 404`,不改状态点、更新时间、卡片和其他交互。 + +## 验收标准 + +- “刷新”和“退出”各自具有清晰、独立的小按钮框。 +- 按钮在普通、按下和刷新禁用状态下仍可辨识。 +- 底栏不增高,状态文字不会被按钮覆盖。 +- 完整测试、Release 构建、签名、配置和差异检查通过。 +- 新版替换当前运行应用;不提交、不推送。 diff --git a/docs/superpowers/specs/2026-07-17-interactive-mochi-theme-reset-card-design.md b/docs/superpowers/specs/2026-07-17-interactive-mochi-theme-reset-card-design.md new file mode 100644 index 0000000..9e77586 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-interactive-mochi-theme-reset-card-design.md @@ -0,0 +1,80 @@ +# CodexMochi 互动猫、柔色主题与重置卡设计 + +## 目标 + +让弹窗顶部的糯米猫可以点击互动,允许用户选择并持久化柔色主题;保留主周额度卡,将次窗口替换为可用重置卡展示;在“今日 Mac 口粮”中加入会随 Token 速度变化的猫粮碗动画。 + +## 信息结构 + +弹窗从上到下保持以下顺序: + +1. 可互动糯米猫、周额度标题、柔色主题按钮。 +2. “本周额度 · 主窗口”卡片。 +3. “重置卡”卡片,替代原“本周额度 · 次窗口”。 +4. “今日 Mac 口粮”卡片,右侧包含猫粮碗动画。 +5. 现有本周百分比节奏卡片、状态与操作按钮。 + +## 重置卡数据 + +- 从现有 `/backend-api/wham/usage` 响应的 `rate_limit_reset_credits` 读取: + - `available_count`:储备重置卡数量。 + - `applicable_available_count`:此刻可实际使用的数量。 +- 当前真实响应为储备 3 张、当前可使用 0 张。 +- 卡片主数字显示储备数量,副文案显示当前可使用数量。 +- 缺字段时显示“暂未读到重置卡”,绝不把缺失数据当作 0。 +- 首版只展示,不提供兑换按钮,不修改账户。 + +## 猫咪互动 + +- 整个猫脸是无边框点击区域,保留键盘可访问性和辅助功能标签。 +- 每次点击按顺序切换:爱心眼、星星眼、害羞脸、眯眼笑、吐舌头。 +- 点击时猫脸轻弹,并显示与表情匹配的短粒子;1.5 秒后恢复到速度驱动的常态表情。 +- 连续点击会立即切到下一种表情,旧的恢复任务不得提前覆盖新表情。 +- 错误状态优先于互动表情,避免隐藏额度读取失败。 + +## 柔色主题 + +主题按钮位于猫旁边,使用小调色盘图标。点击打开原生菜单,提供: + +- 薄荷 +- 天空蓝 +- 薰衣草 +- 樱花粉 +- 蜜桃 +- 奶油黄 +- 燕麦 +- 雾灰 + +选择后立即更新猫、Token 卡、重置卡、按钮和装饰色,并通过 `UserDefaults` 保存。额度健康、警告、危险的绿/黄/红语义颜色不随主题改变。 + +## 猫粮碗动画 + +猫粮碗使用 SwiftUI 矢量形状绘制,不增加外部图片资源。它包含柔色碗体、猫爪标记、环形猫粮颗粒和少量速度粒子。 + +| Token 速度状态 | 动画 | +|---|---| +| 观察中/休息 | 静止,仅轻微呼吸 | +| 小口啃饼 | 左右轻摆 | +| 认真嚼粮 | 缓慢旋转 | +| 抱碗猛吃 | 快速旋转并出现碎屑 | +| 饭碗起飞 | 高速旋转、风线和星星 | + +动画速度直接复用 `TokenSpeedMood`,不会根据额度百分比伪造 Token 速度。开启“减少动态效果”时停止连续旋转,只保留静态状态差异。 + +## 组件边界 + +- `QuotaParser`:解析重置卡数量。 +- `QuotaSnapshot`:携带储备与当前适用数量。 +- `MochiTheme`:主题标识、名称和颜色定义。 +- `CatReaction`:点击顺序、显示文案和恢复规则。 +- `FoodBowlMotion`:将 Token 速度状态映射为动画级别。 +- `PopoverView`:组合布局,不承担网络或解析逻辑。 + +## 测试与验证 + +- 解析完整、缺失和部分重置卡字段。 +- 表情按顺序循环,错误状态优先。 +- 八种主题标识可持久化,未知旧值回退到薄荷。 +- 五档 Token 状态映射到正确猫粮碗动画级别。 +- Swift 全量测试、Release 构建、签名与本地启动均通过。 +- 维持当前分支未提交状态,不推送 GitHub。 diff --git a/docs/superpowers/specs/2026-07-17-local-token-pace-design.md b/docs/superpowers/specs/2026-07-17-local-token-pace-design.md new file mode 100644 index 0000000..6e63944 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-local-token-pace-design.md @@ -0,0 +1,52 @@ +# CodexMochi 今日 Mac Token 与可爱速率设计 + +## 目标 + +在现有周额度展示之外,增加本机当天实际处理的原始 Token 总量和短时 Token 速度。数据仅从 `~/.codex/sessions` 的 Codex 会话日志读取,不上传、不修改日志,也不把原始 Token 描述成官方额度 Token。 + +## 数据口径 + +- “今日”使用用户当前日历和时区,从本地 00:00 开始。 +- 解析 `token_count.info.total_token_usage`,按每个会话累计值的正向增量统计,避免重复累计事件。 +- 分类保留输入、缓存输入、输出和推理输出;主数字使用 `total_tokens`。 +- “最近速度”采用最近 15 分钟的 Token 增量,折算成 Token/小时。 +- 只统计当前 Mac 可见的持久化 Codex 会话;临时会话、其他设备和不可见云端任务不在统计范围内。 + +## 速度状态 + +| 最近 Token/小时 | 状态 | 文案 | +|---:|---|---| +| 无样本 | 观察中 | 糯米猫在找今天的饭碗 | +| 0 | 休息 | 糯米猫舔舔爪,碗里很安静 | +| < 10 万 | 轻尝 | 糯米猫小口啃饼 | +| < 100 万 | 进餐 | 糯米猫认真嚼口粮 | +| < 500 万 | 猛吃 | 糯米猫抱着饭碗猛吃 | +| >= 500 万 | 起飞 | 糯米猫端着饭碗起飞了 | + +## 界面 + +新增淡蓝色 Token 卡片,置于周额度卡片与现有百分比节奏卡片之间: + +```text +┌ 今日 Mac 口粮 原始 Token ┐ +│ 2,890 万 最近 15 分钟 │ +│ 输入 / 缓存 / 输出 1,584 万 / 时 │ +│ 糯米猫端着饭碗起飞了 │ +└───────────────────────────────────────┘ +``` + +周额度速度继续显示 `% / 时`;Token 卡显示 `Token / 时`,避免把两种速率混为一谈。Token 速度只影响 Token 卡片中的趣味文案,菜单栏猫的最终动效取周额度紧张程度和 Token 活跃程度中的较高者。 + +## 错误处理与性能 + +- 日志目录不存在、没有权限或没有当日样本时显示“等待本机记录”,不显示虚构数字。 +- 跳过损坏的 JSONL 行和缺字段事件,其余文件继续统计。 +- 每分钟随额度刷新重新扫描当天目录;当前日志规模较小,首版优先保证简单可靠。 + +## 测试 + +- 同一会话累计 Token 正向增长正确计入,重复累计值不重复计算。 +- 会话累计值重置时仅计入新的正值,不产生负数。 +- 跨日事件只计入本地当天。 +- 最近 15 分钟速率和五档可爱状态边界正确。 +- 损坏行、缺失目录和空数据返回诚实的空状态。 diff --git a/docs/superpowers/specs/2026-07-17-popping-kibble-burst-design.md b/docs/superpowers/specs/2026-07-17-popping-kibble-burst-design.md new file mode 100644 index 0000000..6cc8dc0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-popping-kibble-burst-design.md @@ -0,0 +1,28 @@ +# 爆猫粮动效设计 + +## 目标 + +替换“猫粮碗整体旋转”的表现。饭碗保持稳定,猫粮像爆米花一样向外喷出并渐隐;Token 消耗越快,喷出的猫粮越密集,快速状态加入火花,非常快时火花与猫粮向四周直溅。 + +## 速度分级 + +- `learning` / `resting`:饭碗静止,不生成粒子。 +- `nibbling`:每轮偶尔生成 1 颗猫粮,短距离向上冒出并渐隐。 +- `munching`:每轮生成 2–3 颗猫粮,沿不同角度向上喷出并渐隐。 +- `gobbling`:生成更密集的猫粮,并加入短火花。 +- `flying`:猫粮与长火花高速向四周直溅,粒子离开饭碗后渐隐。 + +## 实现边界 + +- 使用 SwiftUI `TimelineView` 和确定性粒子槽位实现,不在每一帧重新随机生成,避免闪烁和轨迹跳变。 +- 核心层把 `TokenSpeedMood` 映射为可测试的爆发样式,包括猫粮数量、火花数量、周期和扩散强度。 +- 界面层仅负责根据样式计算粒子的位置、缩放和透明度。 +- 饭碗本体不旋转、不抖动;主题颜色继续用于饭碗和火花,猫粮保持暖棕色。 +- 开启 macOS“减少动态效果”时不播放粒子位移动画,只保留静态饭碗。 + +## 验收标准 + +- 五档速度映射有自动化测试。 +- `gobbling` 明确出现短火花,`flying` 的粒子数量、扩散距离和火花长度明显更高。 +- 原有额度、重置卡、主题和点击小猫功能不受影响。 +- 完整测试、Release 构建、签名和 `Info.plist` 校验通过,并替换当前本机菜单栏进程。 diff --git a/docs/superpowers/specs/2026-07-17-remove-popover-blank-space-design.md b/docs/superpowers/specs/2026-07-17-remove-popover-blank-space-design.md new file mode 100644 index 0000000..59766b3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-remove-popover-blank-space-design.md @@ -0,0 +1,23 @@ +# 弹窗空白压缩设计 + +## 目标 + +移除 Codex Mochi 三张信息卡与底栏之间不承载信息的大块空白,让弹窗高度贴合现有内容,同时保持宽度、信息、交互和视觉风格不变。 + +## 设计 + +- 弹窗宽度继续为 `348pt`。 +- 弹窗高度由 `500pt` 调整为 `404pt`。 +- 删除卡片区与底栏之间会自动扩张的 `Spacer`,改为固定 `6pt` 间距。 +- `PopoverView` 根视图和 `NSPopover.contentSize` 使用相同的 `348 × 404` 尺寸,避免 AppKit 容器再次产生额外空间。 +- 头部、三张卡片、会员与主题菜单、小猫互动、猫粮动画、状态、刷新和退出按钮全部保持不变。 +- 不修改额度、Token、会员识别、刷新周期或任何数据逻辑。 + +## 验收标准 + +- 本机弹窗中,今日 Mac 口粮卡下方只保留约 `6pt` 间距后进入底栏。 +- 三张卡片与底栏均完整显示,没有裁切或重叠。 +- 源码中不再存在用于撑开主内容与底栏的 `Spacer(minLength: 6)`。 +- `PopoverView` 和 `NSPopover` 的尺寸均为 `348 × 404`。 +- 完整测试、应用构建、签名、配置和差异检查通过。 +- 新版替换当前运行的本机应用;不提交、不推送。