From 11cf600b4a266bcbc22d775eb408cba4b4b39c10 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Sat, 3 Oct 2026 19:32:37 +0530 Subject: [PATCH] Clarify provider allowance cards and source details --- DESIGN.md | 8 ++ README.md | 2 +- Sources/ContextDaddy/UsageSourcePanels.swift | 116 +++++++++++------- .../UsageAllowanceViewTests.swift | 73 +++++++---- 4 files changed, 136 insertions(+), 63 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e42e64e..6c0e9c0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,6 +8,14 @@ SwiftUI windows at 960, 1180 and 1440 logical points, captured at the display's 2x backing scale. Credit balance uses the existing secondary-text treatment and stays separate from reset grants, dollar amounts and local history. +The allowance grouping pass uses direction A with the owner's constraint to make +few changes from the current design. The two existing provider cards keep their +headings, percentage meters and controls. Labelled credit and reset-grant groups +sit below the meters; source/check details use native disclosure. Claude grant +counts stay distinct from scheduled resets, with an explicit unreported state +and a link to Claude Usage when the CLI supplies no count. Native full-window +evidence covers 960, 1180 and 1440 points, including missing and stale readings. + ## Selected direction Owner-directed StorageDaddy family fork: keep the A+C information architecture (live operations plus a policy ledger), but express it through StorageDaddy's warm black-and-mint visual language, rounded typography, fine outlines, and editorial Daddy doodles. diff --git a/README.md b/README.md index 38e574b..b4f4cee 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The primary navigation is **Usage**, **Skills**, **Projects**, and **OpenTelemet - Usage first scan shows side-by-side Codex/Claude allowance followed by one local history chart including Devin. The historical chart has shared agent filters, model/model-provider/project grouping where supported, range, day/week/month scale, generated/cache/cost metrics where available, selectable periods and exact breakdown. Model, project, and session drill-downs remain below. Cache reads, generated tokens, and estimated cost stay separate; unpriced models are flagged. - Skills can run an explicit, read-only **Read local history** scan. Claude and Devin `skill` tool calls are counted separately from Cursor/Grok skill-file reads and Codex tool calls that reference a `SKILL.md` path. The library shows last-seen dates, agent and folder evidence, 30/90-day or all-history windows, and an evidence-only filter. Cursor dates use transcript modification time because its records lack event timestamps. Name-only tool calls cannot identify a particular same-name copy; no recorded event never means unused. This backfill is held in memory for the app session and does not automatically remove skills. - Project grouping is a separately labelled session ledger, joining ccusage session IDs to Codex's read-only thread-index `cwd`, Grok's project paths, and Claude's encoded project slugs. It queries only Codex rollout path and working directory metadata, never prompt bodies. Buckets use session last activity, not an invented daily allocation; totals may not reconcile to daily accounting. Missing session identity remains **Unattributed**. Model-provider labels are inferred from reported model names, not billing endpoints; unknown aliases remain unknown. Devin's indexed daily tokens participate in the shared model and provider grouping; project attribution remains unavailable. -- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant. +- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown in the labelled Reset grants section when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero, with a direct link to Claude Settings > Usage. Scheduled reset times stay with their allowance meters, and Source & reading details discloses the provider source and full check time. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant. - Skills and OTEL review panels can **Copy all issues** into an agent-ready brief with IDs, evidence, source scope, and verification limits. After skill edits, **Verify after changes** rescans and distinguishes detector-cleared, still-detected, and unverified findings. The library supports previewed local skill edits with recovery; OTEL signals need a new comparable observation window. - Files & diagnostics configuration findings have the same copy-all and rescan handoff without copying configuration values or modifying files. This file audit detects the two misplaced `otel.*` keys but cannot detect launch-time `session-flags.token_budget`; that warning must be traced to the launcher supplying the flag. - Cursor remains inventory-only for usage until a verified source exists. diff --git a/Sources/ContextDaddy/UsageSourcePanels.swift b/Sources/ContextDaddy/UsageSourcePanels.swift index 8e44580..e22f7bb 100644 --- a/Sources/ContextDaddy/UsageSourcePanels.swift +++ b/Sources/ContextDaddy/UsageSourcePanels.swift @@ -64,53 +64,72 @@ struct UsageAllowanceView: View { .foregroundStyle(DaddyTheme.muted) } Spacer() - Text(status == nil ? "NOT CHECKED" : status?.status == "ready" + Text(error != nil ? (status == nil ? "UNAVAILABLE" : "STALE") : status == nil ? "NOT CHECKED" : status?.status == "ready" ? (windows.isEmpty ? "READY" : health(worst).0.uppercased()) : "UNAVAILABLE") .font(.caption2.weight(.bold)) - .foregroundStyle(status?.status == "ready" && error == nil ? health(worst).1 : DaddyTheme.muted) + .foregroundStyle(error != nil ? DaddyTheme.amber : status?.status == "ready" ? health(worst).1 : DaddyTheme.muted) } if status?.status == "ready" { - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 14) { - ForEach(windows, id: \.id) { window in - windowValue(window).frame(maxWidth: .infinity, alignment: .leading) - } - } - VStack(alignment: .leading, spacing: 12) { - ForEach(windows, id: \.id) { window in windowValue(window) } + HStack(alignment: .top, spacing: 14) { + ForEach(windows, id: \.id) { window in + windowValue(window).frame(maxWidth: .infinity, alignment: .leading) } } - if let status, let creditText = Self.creditText(status) { - Text(creditText).font(.caption2.weight(.semibold)) - .foregroundStyle(DaddyTheme.muted) - .fixedSize(horizontal: false, vertical: true) - if let resets = status.resetCredits { - Text(resetCountText(resets, provider: provider)) - .font(.caption2).foregroundStyle(DaddyTheme.muted) + if let status { + Divider().overlay(DaddyTheme.line) + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(provider == "claude" ? "PAID USAGE CREDITS" : "CREDIT BALANCE") + .font(.caption2.weight(.bold)).foregroundStyle(DaddyTheme.muted) + Text(Self.creditText(status) ?? "Not reported") + .font(.callout.weight(.semibold)).monospacedDigit() + .fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 6) { + Text("RESET GRANTS") + .font(.caption2.weight(.bold)).foregroundStyle(DaddyTheme.muted) + Text(Self.resetCountText(status)) + .font(.callout.weight(.semibold)).monospacedDigit() + .fixedSize(horizontal: false, vertical: true) + if provider == "claude", status.resetCredits == nil { + Text("Claude Code did not report a count.") + .font(.caption2).foregroundStyle(DaddyTheme.muted) + claudeUsageLink + } + if provider == "codex", status.resetCredits != nil { + resetExpiry(status) + } + }.frame(maxWidth: .infinity, alignment: .leading) } - } else if let resets = status?.resetCredits { - Text(resetCountText(resets, provider: provider)) - .font(.caption2).foregroundStyle(DaddyTheme.muted) - } - if provider == "claude", status?.resetCredits == nil { - Text("Usage reset count not reported") - .font(.caption2).foregroundStyle(DaddyTheme.muted) - } - if let status, provider == "codex", status.resetCredits != nil { - resetExpiry(status) } } else { Text(status?.message ?? "Check both allowances for an account reading.") .font(.caption).foregroundStyle(DaddyTheme.muted) .fixedSize(horizontal: false, vertical: true) + if provider == "claude", error != nil { claudeUsageLink } } if let error { Text("Latest check failed; any saved reading may be stale. \(error)") .font(.caption2).foregroundStyle(DaddyTheme.amber) } if let status { - Text("\(status.source) · \(status.checkedAt)") - .font(.caption2).foregroundStyle(DaddyTheme.muted).lineLimit(1) + Divider().overlay(DaddyTheme.line) + DisclosureGroup("Source & reading details") { + VStack(alignment: .leading, spacing: 5) { + Text(status.source) + Text("Checked · \(status.checkedAt)") + if provider == "codex", + let nonExpiring = status.resetCreditsWithoutExpiryCount, nonExpiring > 0, + status.latestReportedResetCreditExpiryUnix != nil { + Text("\(nonExpiring) reported \(nonExpiring == 1 ? "grant has" : "grants have") no expiry.") + } + } + .font(.caption2).foregroundStyle(DaddyTheme.muted) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 5) + }.font(.caption).foregroundStyle(DaddyTheme.muted) } }.frame(maxWidth: .infinity, alignment: .leading) } @@ -145,13 +164,14 @@ struct UsageAllowanceView: View { if let pace = paceLabel(window) { Text(pace).font(.caption2.monospaced()).foregroundStyle(DaddyTheme.muted) } - if let reset = window.resetDescription { - Text("Resets \(reset)").font(.caption2.monospaced()).foregroundStyle(DaddyTheme.muted) + if let reset = Self.windowResetText(window) { + Text(reset).font(.caption2.monospaced()).foregroundStyle(DaddyTheme.muted) + .fixedSize(horizontal: false, vertical: true) } } .frame(minWidth: 0, maxWidth: 190, alignment: .leading) .accessibilityElement(children: .combine) - .accessibilityLabel("\(window.label), \(Int(window.remainingPercent.rounded())) percent remaining") + .accessibilityLabel("\(window.label), \(Int(window.remainingPercent.rounded())) percent remaining. \(Self.windowResetText(window) ?? "")") } private func health(_ remaining: Double) -> (String, Color) { @@ -194,8 +214,27 @@ struct UsageAllowanceView: View { return nil } - private func resetCountText(_ count: UInt64, provider: String) -> String { - "\(count) \(provider == "claude" ? "usage" : "full") \(count == 1 ? "reset" : "resets") available" + static let claudeUsageURL = URL(string: "https://claude.ai/settings/usage")! + + private var claudeUsageLink: some View { + Link(destination: Self.claudeUsageURL) { + HStack(spacing: 3) { + Text("View Claude Usage") + Image(systemName: "arrow.up.right") + }.font(.caption2) + } + .buttonStyle(.plain).foregroundStyle(DaddyTheme.mint) + } + + static func resetCountText(_ status: ProviderQuotaStatus) -> String { + guard let count = status.resetCredits else { return "Not reported" } + return "\(count) \(status.provider == "claude" ? "usage" : "full") \(count == 1 ? "reset" : "resets") available" + } + + static func windowResetText(_ window: ProviderQuotaWindow) -> String? { + if let description = window.resetDescription { return "Resets \(description)" } + guard let unix = window.resetsAtUnix else { return nil } + return "Resets \(Date(timeIntervalSince1970: TimeInterval(unix)).formatted(date: .abbreviated, time: .shortened))" } @ViewBuilder private func resetExpiry(_ status: ProviderQuotaStatus) -> some View { @@ -204,16 +243,11 @@ struct UsageAllowanceView: View { .font(.caption2.weight(.semibold)).foregroundStyle(DaddyTheme.muted) } else if (status.resetCredits ?? 0) > 0 { Text(status.resetCreditsWithoutExpiryCount == status.resetCredits - ? "Reported credits do not expire" : "Reset-credit expiry unavailable") - .font(.caption2).foregroundStyle(DaddyTheme.muted) - } - if let nonExpiring = status.resetCreditsWithoutExpiryCount, nonExpiring > 0, - status.latestReportedResetCreditExpiryUnix != nil { - Text("\(nonExpiring) reported \(nonExpiring == 1 ? "credit has" : "credits have") no expiry.") + ? "Reported reset grants do not expire" : "Reset-grant expiry unavailable") .font(.caption2).foregroundStyle(DaddyTheme.muted) } if let details = status.resetCreditDetailsCount, details < (status.resetCredits ?? 0) { - Text("Only \(details) of \(status.resetCredits ?? 0) credit details reported; a later expiry may exist.") + Text("Only \(details) of \(status.resetCredits ?? 0) grant details reported; a later expiry may exist.") .font(.caption2).foregroundStyle(DaddyTheme.amber) } } diff --git a/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift index f25d105..ae24b25 100644 --- a/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift +++ b/Tests/ContextDaddyTests/UsageAllowanceViewTests.swift @@ -25,38 +25,69 @@ struct UsageAllowanceViewTests { #expect(try UsageAllowanceView.creditText(status(#"{"used_amount":25,"limit_amount":150}"#, provider: "claude")) == "\(dollars) credits") } + @Test func resetGrantLabelsKeepUnknownDistinctFromZero() throws { + func status(_ count: String, provider: String = "claude") throws -> ProviderQuotaStatus { + let json = "{\"provider\":\"\(provider)\",\"status\":\"ready\",\"source\":\"fixture\",\"checked_at\":\"2026-10-03T12:00:00Z\",\"windows\":[],\"reset_credits\":\(count)}" + return try JSONDecoder().decode(ProviderQuotaStatus.self, from: Data(json.utf8)) + } + #expect(try UsageAllowanceView.resetCountText(status("null")) == "Not reported") + #expect(try UsageAllowanceView.resetCountText(status("0")) == "0 usage resets available") + #expect(try UsageAllowanceView.resetCountText(status("1")) == "1 usage reset available") + #expect(try UsageAllowanceView.resetCountText(status("2")) == "2 usage resets available") + #expect(try UsageAllowanceView.resetCountText(status("2", provider: "codex")) == "2 full resets available") + let window = try JSONDecoder().decode(ProviderQuotaWindow.self, from: + Data(#"{"id":"current","label":"Current window","remaining_percent":80,"reset_description":"8:30pm (Asia/Calcutta)"}"#.utf8)) + #expect(UsageAllowanceView.windowResetText(window) == "Resets 8:30pm (Asia/Calcutta)") + } + /// Focused native evidence; no local-history scan or provider call is needed. - @Test func rendersCreditEvidence() throws { + @Test func rendersAllowanceGrouping() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() - let directory = root.appendingPathComponent("artifacts/design/codex-credits/current", isDirectory: true) + let directory = root.appendingPathComponent("artifacts/design/allowance-layout/after", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let model = ContextDaddyModel() + let model = ContextDaddyModel(discover: { _ in throw CancellationError() }) let originalAutoCheck = model.autoCheckAllowance defer { model.autoCheckAllowance = originalAutoCheck } model.autoCheckAllowance = false - let fixture = #"{"schema_version":"contextdaddy.provider-quota/v1","generated_at":"2026-10-03T12:00:00Z","providers":[{"provider":"codex","status":"ready","source":"Codex fixture","checked_at":"2026-10-03T12:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"5-hour window","remaining_percent":70}],"credits":{"balance":12345.6789,"has_credits":true,"unlimited":false},"reset_credits":2,"message":null},{"provider":"claude","status":"ready","source":"Claude fixture","checked_at":"2026-10-03T12:00:00Z","windows":[{"id":"current","label":"Current window","remaining_percent":80},{"id":"weekly","label":"Weekly window","remaining_percent":60}],"credits":{"used_amount":25,"limit_amount":150},"reset_credits":1,"message":null}]}"# + model.show(.overview) + let fixture = #"{"schema_version":"contextdaddy.provider-quota/v1","generated_at":"2026-10-03T12:00:00Z","providers":[{"provider":"codex","status":"ready","source":"codex app-server account/rateLimits/read","checked_at":"2026-10-03T12:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"5-hour window","remaining_percent":70,"reset_description":"4 Oct at 5:30pm (Asia/Calcutta)"}],"credits":{"balance":12345.6789,"has_credits":true,"unlimited":false},"reset_credits":2,"latest_reported_reset_credit_expiry_unix":1793386560,"reset_credit_details_count":1,"message":null},{"provider":"claude","status":"ready","source":"Claude Code /usage","checked_at":"2026-10-03T12:00:00Z","plan":"Claude Team","windows":[{"id":"current","label":"Current window","remaining_percent":80,"reset_description":"8:30pm (Asia/Calcutta)"},{"id":"weekly","label":"Weekly window","remaining_percent":60,"reset_description":"Oct 4 at 5:29pm (Asia/Calcutta)"}],"credits":{"used_amount":25,"limit_amount":150},"reset_credits":1,"message":null}]}"# let receipt = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(fixture.utf8)) model.quotaReceipts["codex"] = receipt model.quotaReceipts["claude"] = receipt for width in [960, 1180, 1440] { - let view = FocusDeskView().environment(model) - .frame(width: CGFloat(width), height: 740, alignment: .topLeading) - .background(DaddyTheme.canvas).preferredColorScheme(.dark) - .tint(DaddyTheme.mint).buttonStyle(ContextDaddyButtonStyle()) - let hosting = NSHostingView(rootView: view) - hosting.frame = NSRect(x: 0, y: 0, width: width, height: 740) - let window = NSWindow(contentRect: hosting.frame, styleMask: [], backing: .buffered, defer: false) - window.contentView = hosting - hosting.layoutSubtreeIfNeeded() - window.displayIfNeeded() - RunLoop.current.run(until: Date().addingTimeInterval(0.25)) - hosting.layoutSubtreeIfNeeded() - window.displayIfNeeded() - let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) - hosting.cacheDisplay(in: hosting.bounds, to: bitmap) - let png = try #require(bitmap.representation(using: .png, properties: [:])) - try png.write(to: directory.appendingPathComponent("allowance-\(width).png"), options: .atomic) + try render(model, width: width, height: 740, to: directory.appendingPathComponent("allowance-\(width).png")) } + let unreported = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: + Data(fixture.replacingOccurrences(of: "\"reset_credits\":1", with: "\"reset_credits\":null").utf8)) + model.quotaReceipts["claude"] = unreported + try render(model, width: 960, height: 640, to: directory.appendingPathComponent("unreported-960.png")) + let depleted = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: + Data(fixture.replacingOccurrences(of: "\"reset_credits\":1", with: "\"reset_credits\":0").utf8)) + model.quotaReceipts["claude"] = depleted + model.quotaErrors["claude"] = "Fixture: allowance unavailable." + try render(model, width: 960, height: 640, to: directory.appendingPathComponent("depleted-stale-960.png")) + model.quotaReceipts.removeValue(forKey: "claude") + try render(model, width: 960, height: 640, to: directory.appendingPathComponent("unavailable-960.png")) + } + + private func render(_ model: ContextDaddyModel, width: Int, height: Int, to destination: URL) throws { + let view = RootView().environment(model) + .frame(width: CGFloat(width), height: CGFloat(height), alignment: .topLeading) + .background(DaddyTheme.canvas).preferredColorScheme(.dark) + .tint(DaddyTheme.mint).buttonStyle(ContextDaddyButtonStyle()) + let hosting = NSHostingView(rootView: view) + hosting.frame = NSRect(x: 0, y: 0, width: width, height: height) + let window = NSWindow(contentRect: hosting.frame, styleMask: [], backing: .buffered, defer: false) + window.contentView = hosting + hosting.layoutSubtreeIfNeeded() + window.displayIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + hosting.layoutSubtreeIfNeeded() + window.displayIfNeeded() + let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: destination, options: .atomic) } }