From 0a228ab7583afc5ac515f31ebb8612e8ce03ad5b Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:29:07 -0400 Subject: [PATCH] fix(pacing): report window pace on displays, gate only present-tense claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quota window's shape (usedPercent ahead of the elapsed fraction) can hold a pace deficit long after the burst that caused it. The burn-recency gate shipped for the failover nudge was applied to every display too, so a bar, banner, or menu-bar chip flipped to "on pace" and dropped its ETA 30 minutes after the last local session โ€” a weekly at 43% with 6 days left read as if nothing were ahead of pace. Displays now consume QuotaPace.pace(now:) directly: the bar, side-notch banner and rows, and the menu-bar chip keep a deficit's projected run-out whatever the last burn recency. The present-tense claims stay gated on BurnRecency via QuotaPace.effective(lastBurn:now:): the failover nudge, pace alerts, and the meterusage json report, so the stale-nudge fix that motivated the gate is unchanged. Verification: swift build clean and swift test 362/362 on macOS arm64, Swift 6.4, including the new SideNotchPanelRegressionTests case. --- CHANGELOG.md | 12 +++++++ README.md | 4 +-- Sources/MeterUsage/Views/MenuBarLabel.swift | 12 +++---- Sources/MeterUsage/Views/PopoverRoot.swift | 4 +-- Sources/MeterUsage/Views/QuotaSection.swift | 22 +++++------- .../MeterUsage/Views/SideNotchPanelView.swift | 34 +++++++------------ .../MeterUsageTests/SideNotchPanelTests.swift | 21 ++++++++++++ docs/KB.md | 2 +- 8 files changed, 61 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f67d72..7b89476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- **Pace displays stop hiding a deficit once the burn cools.** The quota bar, + side-notch banner and window rows, and the menu-bar ETA chip now report the + window's own pace, so a window ahead of the even-burn line keeps its + projected run-out โ€” a weekly at 43% with 6 days left โ€” instead of flipping to + "on pace" 30 minutes after the last local session. The present-tense + "burning fast" failover nudge, pace alerts, and `meterusage json` pacing stay + gated on a current burn, so the stale-nudge fix is unchanged. + ## [0.2.38] - 2026-09-26 ### Added diff --git a/README.md b/README.md index 82c8687..5c028df 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,14 @@ Toggle providers on or off, choose refresh cadence, and switch themes: ## ๐Ÿš€ Key Features -* **Ambient Time-to-Empty** โ€” Dynamic ETA calculations (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch, warning you of rapid burn cliffs before limits are reached. Pacing claims are present tense: a window whose burn has gone quiet reports the reset countdown instead of a burn alarm. +* **Ambient Time-to-Empty** โ€” Dynamic ETA calculations (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch, warning you of rapid burn cliffs before limits are reached. The bar, banner, and menu-bar chip report the window's own pace, so a deficit keeps its projected run-out after the burst has cooled; only the present-tense "burning fast" nudge and pace alerts require a current burn. * **Window Burn Attribution** โ€” Explains *"Where did my tokens go?"* by decomposing the last 7 days of token consumption by project, model, and message turns across all token-bearing providers. * **Context Waste & Cache Hints** โ€” Diagnostic metadata highlighting cache hit rate %, average tokens per turn, and long-chat flags ($\ge 10$ turns or $\ge 100\text{k}$ tokens) to curb silent context waste. * **Durable Daily History Store** โ€” Preserves daily token tallies, estimated spend, and peak window utilization in a durable local database (`~/Library/Application Support/MeterUsage/durable-daily-history.json`) that survives CLI transcript pruning. * **Unified AI Coding Strip** โ€” High-level summary card in the popover showing all AI coding today (tokens, weekly volume, and estimated USD spend) across all active providers. * **Floating Side Notch HUD** โ€” Fixed-black, hardware-like collapsible strip. Hovering a ring expands a docked detail card with smooth spring animations. Features auto-flip positioning (switches left/right depending on screen position). The strip stays anchored to its parked corner while cards change, so provider switches never shift it. * **Cross-Provider Headroom Failover** โ€” Instant suggestions when a provider is burning fast or near exhaustion, identifying which alternative model has headroom available. "Burning fast" requires a current burn; a window that is merely near its limit says so in its own words. -* **Agent Budget API** โ€” Run `meterusage json` to export machine-readable quota telemetry, burn velocity, pacing status, and seconds-to-empty for autonomous AI agents. Pacing is gated on burn recency the same way the UI is. +* **Agent Budget API** โ€” Run `meterusage json` to export machine-readable quota telemetry, burn velocity, pacing status, and seconds-to-empty for autonomous AI agents. Pacing in the report is gated on burn recency, matching the failover nudge and pace alerts. * **26-Week Activity Heatmaps** โ€” GitHub-style activity matrix inside Codex and Claude cards with Day, Week, or Cumulative views, accompanied by 7-day sparklines. * **Opt-In Pacing Alerts** โ€” Native macOS notifications when an active window crosses critical burn velocity or drops below 30 minutes to empty. Pace alerts fire only on a current burn; threshold alerts (80%/95%) remain state-based. * **Share screenshot**: The share button on each provider's usage card (side notch panel detail card) shares a sharp 2x image of the panel through macOS share services, or saves it for X and other apps. diff --git a/Sources/MeterUsage/Views/MenuBarLabel.swift b/Sources/MeterUsage/Views/MenuBarLabel.swift index 71f0646..87dcab7 100644 --- a/Sources/MeterUsage/Views/MenuBarLabel.swift +++ b/Sources/MeterUsage/Views/MenuBarLabel.swift @@ -159,14 +159,10 @@ struct MenuBarLabel: View { } if let window { - // The effective pace demotes a deficit whose burn has gone - // quiet, so the ambient chip shows a burn ETA only while the - // provider is actually burning; a stale window falls through - // to the honest reset countdown (or no chip at all). - let pace = window.pace(now: coordinator.clock)?.effective( - lastBurn: BurnRecency.lastBurn( - of: coordinator.activities[slot]?.value?.sessions ?? []), - now: coordinator.clock) + // The chip reports the window's own pace: a deficit shows the + // projected exhaustion while the window is ahead of the + // even-burn line, however long ago the burst that caused it. + let pace = window.pace(now: coordinator.clock) let showAmbient = pace?.shouldShowAmbientETA(resetsAt: window.resetsAt, now: coordinator.clock) ?? false let eta = showAmbient ? pace?.etaText(resetsAt: window.resetsAt, now: coordinator.clock, short: true) : nil let isDeficit = pace?.status.isDeficit ?? false diff --git a/Sources/MeterUsage/Views/PopoverRoot.swift b/Sources/MeterUsage/Views/PopoverRoot.swift index a8d5302..0dd57fa 100644 --- a/Sources/MeterUsage/Views/PopoverRoot.swift +++ b/Sources/MeterUsage/Views/PopoverRoot.swift @@ -364,9 +364,7 @@ struct PopoverRoot: View { // pass nothing and render unchanged. Each account // slot shades its own activity. heatmapDaily: heatmapDaily(for: slot), - heatmapIntensity: slot.provider == .codex ? .sessions : .tokens, - lastBurn: BurnRecency.lastBurn( - of: coordinator.activities[slot]?.value?.sessions ?? []) + heatmapIntensity: slot.provider == .codex ? .sessions : .tokens ) } } diff --git a/Sources/MeterUsage/Views/QuotaSection.swift b/Sources/MeterUsage/Views/QuotaSection.swift index b0ba417..acb1154 100644 --- a/Sources/MeterUsage/Views/QuotaSection.swift +++ b/Sources/MeterUsage/Views/QuotaSection.swift @@ -35,10 +35,6 @@ struct QuotaSection: View { /// Codex has no token ledger and shades by sessions; token-bearing /// sources (Claude) shade by tokens. var heatmapIntensity: HeatmapView.Intensity = .tokens - /// The provider's most recent observable burn. Gates the pacing label: - /// a pace deficit without a current burn is the window's past, not a - /// present-tense "burning fast" (see `BurnRecency`). - var lastBurn: Date? = nil @State private var resetPrompt: ResetPrompt? @State private var consumingResetID: String? @@ -54,8 +50,7 @@ struct QuotaSection: View { now: Date, onUseReset: ((String) async throws -> Void)? = nil, heatmapDaily: [DailyActivity] = [], - heatmapIntensity: HeatmapView.Intensity = .tokens, - lastBurn: Date? = nil + heatmapIntensity: HeatmapView.Intensity = .tokens ) { self.slot = slot self.state = state @@ -64,7 +59,6 @@ struct QuotaSection: View { self.onUseReset = onUseReset self.heatmapDaily = heatmapDaily self.heatmapIntensity = heatmapIntensity - self.lastBurn = lastBurn } var body: some View { @@ -222,7 +216,7 @@ struct QuotaSection: View { .foregroundColor(MU.text) } ForEach(Array(group.windows.enumerated()), id: \.offset) { _, window in - WindowRow(provider: provider, window: window, now: now, lastBurn: lastBurn) + WindowRow(provider: provider, window: window, now: now) } } } @@ -352,8 +346,6 @@ private struct WindowRow: View { let provider: Provider let window: QuotaWindow let now: Date - /// Gates the pacing label on burn recency (see `QuotaSection.lastBurn`). - let lastBurn: Date? @AppStorage(PrefKey.showPacingBurnRate) private var showPacingBurnRate: Bool = true @@ -391,10 +383,12 @@ private struct WindowRow: View { .foregroundColor(MU.textTertiary) .lineLimit(1) .minimumScaleFactor(0.7) - // Effective pace: a deficit whose burn went quiet reads - // as on-pace, so the label never calls an idle day - // "burning fast". - if showPacingBurnRate, let pace = window.pace(now: now)?.effective(lastBurn: lastBurn, now: now) { + // The bar reports the window's pace as its shape: a + // deficit means the window is ahead of where even burn + // would put it, whatever the last burn recency. The + // present-tense "burning fast" claim (and its nudge) is + // what recency gates, not this figure. + if showPacingBurnRate, let pace = window.pace(now: now) { Text("ยท") .font(.muCaption) .foregroundColor(MU.textTertiary) diff --git a/Sources/MeterUsage/Views/SideNotchPanelView.swift b/Sources/MeterUsage/Views/SideNotchPanelView.swift index 495fd4b..fded2fa 100644 --- a/Sources/MeterUsage/Views/SideNotchPanelView.swift +++ b/Sources/MeterUsage/Views/SideNotchPanelView.swift @@ -552,14 +552,12 @@ struct SideNotchPanelView: View { StatusBadge(severity: status.severity) } - // Ambient Time-To-Empty banner. The effective pace demotes a - // deficit whose burn has gone quiet, so a stale window shows the - // reset countdown in the calm tint instead of a burn alarm. + // Ambient Time-To-Empty banner. It reports the window's own pace: + // a deficit empties before reset at that shape even when the burn + // has since gone quiet. Only the present-tense "burning fast" + // nudge and alerts are gated on burn recency. if let headline = slot.provider.headlineWindow(from: Self.effectiveWindows(for: slot.provider, quota: quota)), - let pace = headline.pace(now: coordinator.clock)?.effective( - lastBurn: BurnRecency.lastBurn( - of: coordinator.activities[slot]?.value?.sessions ?? []), - now: coordinator.clock), + let pace = headline.pace(now: coordinator.clock), let etaText = pace.etaText(resetsAt: headline.resetsAt, now: coordinator.clock) { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { @@ -780,14 +778,11 @@ struct SideNotchPanelView: View { @ViewBuilder private func windowRow(window: QuotaWindow, quota: ProviderQuota?, provider: Provider, slot: ProviderSlot) -> some View { - // Effective pace: a deficit without a current burn reads as on-pace, - // never as a "burning fast" alarm for a burst that already cooled. - // Burn evidence is per account slot. + // The row reports the window's own pace: a deficit means the window + // is ahead of the even-burn line, and it carries the projected + // exhaustion even when the burst that caused it has gone quiet. let pace = showPacingBurnRate - ? window.pace(now: coordinator.clock)?.effective( - lastBurn: BurnRecency.lastBurn( - of: coordinator.activities[slot]?.value?.sessions ?? []), - now: coordinator.clock) + ? window.pace(now: coordinator.clock) : nil let figure = Self.windowFigure(window: window, quota: quota, provider: provider) VStack(alignment: .leading, spacing: 4) { @@ -1356,7 +1351,6 @@ struct SideNotchPanelView: View { quotas: [ProviderSlot: Loaded], statuses: [Provider: Loaded], archivedQuotas: [ProviderSlot: ProviderQuota] = [:], - lastBurn: [ProviderSlot: Date] = [:], now: Date = Date() ) -> [Entry] { // Additional accounts number from 2 within their tool, in stable @@ -1378,12 +1372,9 @@ struct SideNotchPanelView: View { // otherwise. markTint = providerColor(slot.provider) } - // Effective pace: a deficit without a current burn is demoted to - // on-pace, so the strip never reports "burning fast" from a - // stale window and the ETA chip carries the honest reset - // countdown instead of a projected exhaustion that already ended. - // Burn evidence is per account slot. - let pace = window.pace(now: now)?.effective(lastBurn: lastBurn[slot], now: now) + // The strip reports the window's own pace: a deficit carries the + // projected exhaustion chip whatever the last burn recency. + let pace = window.pace(now: now) let showAmbient = pace?.shouldShowAmbientETA(resetsAt: window.resetsAt, now: now) ?? false let eta = showAmbient ? pace?.etaText(resetsAt: window.resetsAt, now: now, short: true) : nil let isDeficit = pace?.status.isDeficit ?? false @@ -1414,7 +1405,6 @@ struct SideNotchPanelView: View { quotas: coordinator.quotas, statuses: coordinator.statuses, archivedQuotas: coordinator.archivedQuotas, - lastBurn: coordinator.lastBurnBySlot, now: coordinator.clock ) } diff --git a/Tests/MeterUsageTests/SideNotchPanelTests.swift b/Tests/MeterUsageTests/SideNotchPanelTests.swift index 557f4a9..3a9992a 100644 --- a/Tests/MeterUsageTests/SideNotchPanelTests.swift +++ b/Tests/MeterUsageTests/SideNotchPanelTests.swift @@ -249,6 +249,27 @@ final class SideNotchPanelTests: XCTestCase { // MARK: - Entries + @MainActor + func testEntriesKeepWeeklyDeficitWithoutBurnEvidence() async throws { + // A fresh weekly window at 43% with 6d 13h left is far ahead of pace. + // The strip reports the deficit and its projected run-out even with no + // local session to prove a current burn โ€” only the present-tense + // nudge and pace alerts gate on burn recency. + let coordinator = try await Self.coordinator(quotas: [ + (Provider.codex, [("Weekly", 43.0, 6.0 * 86_400 + 13.0 * 3_600)]), + ]) + + let entries = SideNotchPanelView.entries( + menuBarSlots: coordinator.menuBarSlots, + quotas: coordinator.quotas, + statuses: coordinator.statuses + ) + + XCTAssertEqual(entries.count, 1) + XCTAssertTrue(entries[0].isDeficit) + XCTAssertNotNil(entries[0].etaText) + } + @MainActor func testEntriesTakeHeadlineWindowInMenuBarOrder() async throws { let coordinator = try await Self.coordinator(quotas: [ diff --git a/docs/KB.md b/docs/KB.md index c9216d8..a139fc4 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -18,7 +18,7 @@ Last verified: 2026-09-26 - `CHANGELOG.md` records version 0.2.38 as the latest repository release entry, with multi-account meters for Codex and Claude (ADR 0005โ€“0006) dated 2026-09-26, following the 0.2.37 incident.io outage-vocabulary fix and identity-tinted provider marks. This is repository release-note state, not proof of a published release. - `CONTRIBUTING.md` requires focused changes, synthetic fixtures, and `swift build`, `swift test`, and `Scripts/make-app.sh` before a code pull request. - The side notch panel is anchored by the strip's top-right corner and uses whole-point frames, so switching providers never moves the strip. `docs/SIDE-NOTCH.md` describes the states, geometry invariants, and required evidence. ADR 0004 records the decision. -- Pace claims are gated on burn recency (`BurnRecency` in `Sources/MeterUsage/Models/UsageModels.swift`): a quota window's shape can hold a pace deficit long after the burst that caused it, so "burning fast" requires the provider to have burned within a 30-minute quiet period. Surfaces consume `QuotaPace.effective(lastBurn:now:)`, which demotes a burn-quiet deficit to on-pace. A headline window at 80%+ without a current burn renders as "near its limit", not "burning fast". Providers without a local session store have no burn evidence: they keep the 80%/95% threshold alerts but never raise pace alerts. +- Window-shape displays report the raw pace (`QuotaPace.pace(now:)`): a quota bar, side-notch banner/row, or menu-bar chip keeps a deficit's projected run-out even when the burn that caused it has gone quiet. Present-tense claims are gated on burn recency (`BurnRecency` in `Sources/MeterUsage/Models/UsageModels.swift`): the failover nudge, pace alerts, and the machine report consume `QuotaPace.effective(lastBurn:now:)`, which demotes a burn-quiet deficit to on-pace, and require the provider to have burned within a 30-minute quiet period. A headline window at 80%+ without a current burn renders as "near its limit", not "burning fast". Providers without a local session store have no burn evidence: they keep the 80%/95% threshold alerts but never raise pace alerts. - Burn attribution renders only measured burn (`BurnAttributionCalculator.calculate` in `Sources/MeterUsage/Services/BurnAttributionCalculator.swift`): sessions without a token ledger (for example Codex realtime sessions whose rollout records no `token_count` event) are excluded, and a scope with no token-bearing sessions yields no breakdown, so the burn card hides instead of showing all-zero rows. - GitHub Actions runs `swift build` and `swift test` on macOS 15 for pushes to `main` and pull requests. This repository fact does not prove that a workflow run has passed. - The app preserves unreadable durable history and suspends history writes until relaunch after the file is repaired. Sanitized history load and save failures are visible in Copy diagnostics.