Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 4 additions & 8 deletions Sources/MeterUsage/Views/MenuBarLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions Sources/MeterUsage/Views/PopoverRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
22 changes: 8 additions & 14 deletions Sources/MeterUsage/Views/QuotaSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -64,7 +59,6 @@ struct QuotaSection: View {
self.onUseReset = onUseReset
self.heatmapDaily = heatmapDaily
self.heatmapIntensity = heatmapIntensity
self.lastBurn = lastBurn
}

var body: some View {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
34 changes: 12 additions & 22 deletions Sources/MeterUsage/Views/SideNotchPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1356,7 +1351,6 @@ struct SideNotchPanelView: View {
quotas: [ProviderSlot: Loaded<ProviderQuota>],
statuses: [Provider: Loaded<ServiceStatus>],
archivedQuotas: [ProviderSlot: ProviderQuota] = [:],
lastBurn: [ProviderSlot: Date] = [:],
now: Date = Date()
) -> [Entry] {
// Additional accounts number from 2 within their tool, in stable
Expand All @@ -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
Expand Down Expand Up @@ -1414,7 +1405,6 @@ struct SideNotchPanelView: View {
quotas: coordinator.quotas,
statuses: coordinator.statuses,
archivedQuotas: coordinator.archivedQuotas,
lastBurn: coordinator.lastBurnBySlot,
now: coordinator.clock
)
}
Expand Down
21 changes: 21 additions & 0 deletions Tests/MeterUsageTests/SideNotchPanelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion docs/KB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading