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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@
- Every successful refresh archives into the local SQLite history at `~/Library/Application Support/TokenGauge/usage-history.sqlite` (mode 0600) through `UsageHistoryStore`, off the main actor and best-effort: an archive failure must never block or alter the panel. `quota_samples` keeps percentages and reset instants collapsed into 15-minute buckets and pruned after `quotaRetentionDays`; `daily_tokens` keeps one row per (day, provider, model) and only ever grows (`MAX`), so trimmed transcripts cannot erase recorded history. Codex account totals use the model key `all`; local effort events can supply separate model detail. Never store tokens, account fields, prompts, or responses there.
- Archive quota only from successful original provider results with a capture timestamp, never from mixed UI fallbacks. Archive tokens independently after a successful activity read. Preserve explicit zero days; missing days remain unknown. Never fill missing Claude days from an empty scan.
- Effort scans run in the bounded `TokenGaugeCapture --record-effort-history` subprocess, never the resident UI. Decode only allowlisted metadata; hash response IDs before storage. Deduplicate events using MAX counters and preserve known model/effort values. The daily total is MAX(provider aggregate, summed model rows, summed effort events), never a sum of overlapping sources. Effort events and daily totals have no expiry.
- The same scan records `activity_events`: Claude `Skill` tool names and Codex skills read through a plain `sed|cat|head|nl|less|bat|tail` of `skills/<name>/SKILL.md` (name validated against `[a-z0-9][a-z0-9._:-]{0,63}`, counted once per message/rollout), plus root turn durations (Claude `turn_duration`, Codex `task_complete`; subagents and sidechains excluded). `effort_events.cached_tokens` keeps cache reads. Never store tool arguments, commands, paths or messages. Claude SDK sessions write no `turn_duration`, so turn tiles hide when today has none. The scan checkpoints its state every 5 s so a timed-out cold scan resumes instead of restarting.
- Weekly pace requires at least three verified, same-reset observations spanning 30 minutes within the last hour, with a latest reading no older than 20 minutes. Reset, decrease, invalid reading, or a gap over 30 minutes breaks continuity. Reset timestamps may vary by at most one second against a persisted continuity anchor; larger changes break continuity. Legacy quota rows remain unverified. Archive valid estimates in `pace_samples` without expiry, including idle readings; only newly increased usage updates the last active pace. A current pace must match both the latest verified quota timestamp in SQLite and the visible snapshot capture timestamp. Show retained pace as historical with its measurement date, never as a fresh reading. Never attribute quota pace to a reasoning effort from token counts.
- Calendar reset markers group coincident weekly quotas per provider. The next live date is confirmed; later weeks project the same seven-day interval for a year and are labelled expected. Only ready, visible quotas contribute; cancellation or unavailable data removes the forecast. Future reset cards omit activity and streaks.
- History queries run off the main actor and load only the selected date range; hover and selection never query SQLite or scan logs. Calendar weeks begin Monday. Unknown days must not become zero-use days. Preview/test stores must never read or write the real user history.
- History hover updates the selected detail only on actual pointer movement; layout/scroll/refresh must not select the date beneath a stationary cursor. The annual view centers today on entry and on Today, preserves the viewport during refresh, and highlights today independently of selection. The native calendar positions its canvas synchronously before drawing. Verify real hover, opening, past-date selection, Today and refresh; suppress gutters with `.scrollIndicators(.never)` in SwiftUI and native scroller flags in the calendar.
- Query the history with `scripts/usage_history.sh {status|daily|models|weekly|monthly|quota|export|sql}`; the `daily_totals`, `weekly_totals` (ISO week Monday) and `monthly_totals` views exist so both Steven and an agent read the same aggregation.
- Query the history with `scripts/usage_history.sh {status|daily|models|weekly|monthly|quota|skills|export|sql}`; the `daily_totals`, `weekly_totals` (ISO week Monday) and `monthly_totals` views exist so both Steven and an agent read the same aggregation.

## Architecture

Expand All @@ -48,7 +49,8 @@
- Never leave `popover.animates = false` in steady state: AppKit closes a `.transient` popover on the mouse-down of the status item click, before `togglePopover` runs, so a parked `false` makes that close instant. Disable animation only around the silent re-anchor `show()` and restore it in the same turn.
- No continuous menu bar or hidden-popover animations. The seven-day chart uses one or two native bars per day according to display mode and per-day hover/click targets; hidden providers never affect its legend, summary or scale; never rebuild its data for every cursor pixel. Skip status-item image/title assignment when unchanged.
- Panel style and menu-bar style persist independently; existing installs default to classic panels and numeric indicators. Compact/ring styles retain exact quota and reset data, historical provenance, and provider/window selection. Graphics never show stale values as live; exact menu values remain in tooltip/accessibility. Animations are brief, event-driven, optional, and honor Reduce Motion.
- Use the native segmented control for history modes so selection and labels share one renderer. Year/week transitions must be checked in actual pixels after parent resizing; aligned accessibility frames alone cannot prove that the selection background stayed attached.
- Panel controls are SwiftUI (`HistoryModeControl`, `HistoryPeriodControl`); keep AppKit controls out of the popover. `popoverShouldClose` refuses a close whose current event is a mouse-down inside the panel window unless the app requested it (`requestClose`); the `popover` log category (debug) records every close with its stack. Year/week transitions must be checked in actual pixels after parent resizing.
- Stats is the back face of the panel (`FlipCard`, finite spring flip, back face pinned to the front height so the popover never resizes mid-flip). It is scoped to the selected tab. Forecasts (weekly and 5 h session, `QuotaForecast`) project the server percentage, never tokens: weekly pace sums observed drops over 72 h and 24 h across resets, session pace covers the session so far and its last hour; time on another Claude account is a marked gap that is neither pace nor an early reset (only same-account upward jumps are); the budget is the remaining share per day or per hour left. Every Stats animation is finite and honours `quotaAnimationsEnabled` and Reduce Motion.
- Ring grids must cap columns at the number of visible windows and distribute the occupied row across the card. Two quotas must never leave a reserved third slot; cover this with a wide two-cell render check. Unified ring width follows visible quotas up to 604 pt, enough for one Codex ring and three Claude rings in a single row.
- Centralize visual constants in `Theme.swift`; individual panels are 340 pt wide and Unified is 560 pt wide; the panel grows to fit its content, with the layout matrix verified below 700 pt. Never wrap the entire popover in a vertical scroll view; only unusually long provider lists scroll within their own bounded area. Individual views show only their own provider; Unified shows two titled cards side by side. Scroll only when real provider content exceeds its bounded height.
- The popover closes on any click outside it: `.transient` alone does not dismiss an accessory app's popover when the click lands in another application (Steven, 2026-08-27). `StatusItemController` arms a global mouse-down monitor plus `didResignActiveNotification` while the popover is shown and tears both down in `popoverDidClose`. Keep the monitor to MOUSE events only — a global key monitor would demand Accessibility, which this app must never request.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.7.0] - 2026-09-26

### Added

- Stats: flip the panel to see statistics for the selected tab, Claude, Codex or both. The main card answers whether each weekly limit and the current 5-hour session will last until the reset, with a projection chart, your pace and the most you can use per day or per hour.
- Stats also compares today with a usual day, charts the last 14 days by model, and shows this week, this month, the total, best day, streak, active days, top models, reasoning effort, most used skills, time worked today, the longest turn and the share served from cache.
- Under the 5-hour session in the Claude and Codex views, a line says whether the session lasts at your current pace or when it runs out.
- Stats cards can be collapsed from their header, and the choice is remembered.

### Changed

- The period switcher and year stepper are redrawn with the rest of the panel, and the streak sits centered between them.
- Model names include their version, such as Opus 5.5 or GPT-5.3 Codex. When several models do not fit, the first one is shown and the rest appear on hover.

### Fixed

- A click inside the panel right after reopening it no longer closes it.
- Forecasts ignore the time spent on another Claude account, and returning to an account is no longer shown as an early reset.
- A very large first history scan resumes where it stopped instead of starting over.

## [1.6.0] - 2026-09-23

### Added
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"></a>
</p>

TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Codex quota you have left. Check reset times, browse weekly and yearly activity, and keep a private local history without opening a terminal.
TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Codex quota you have left. Check reset times, see whether your weekly limit and 5-hour session will last, browse weekly and yearly activity, and keep a private local history without opening a terminal.

<p align="center">
<img src="docs/images/readme-year.png" width="604" alt="Annual activity calendar with weekly reset forecasts, Codex quota, and all three Claude quota rings">
</p>
<p align="center"><sub>App views rendered with synthetic demonstration data.</sub></p>

<details>
<summary>Stats: forecasts, today, models, effort, and skills</summary>
<p align="center"><img src="docs/images/stats.png" width="340" alt="Stats view with weekly and 5-hour forecasts, today against a usual day, the last 14 days, top models, reasoning effort, and most used skills"></p>
</details>

<details>
<summary>Classic panels, yearly history, and Settings</summary>
<p align="center"><img src="docs/images/weekly-resets.png" width="440" alt="Weekly reset forecasts and streaks above the local activity calendar"></p>
Expand All @@ -29,6 +34,7 @@ TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Code

## At a glance

- **Stats and forecasts:** flip the panel to see whether each weekly limit and the current 5-hour session will last until its reset, projected from the provider’s own percentage. Stats also compares today with a usual day, charts the last 14 days, and shows streaks, top models, reasoning effort, most used skills, turns, and cache share. Time on another Claude account is marked instead of being mistaken for a reset. Collapse any card you do not need.
- **Three views:** Codex, Claude, or Unified. Individual views show only that provider; Unified puts both quota cards side by side.
- **Flexible layouts:** keep the classic panel, choose compact rows, or use separate quota rings. Switch from the panel header or Settings → Panel.
- **A readable menu bar:** show one or several limits per provider (5-hour, weekly, model weekly), hide either provider, and choose percentages, mini bars, or mini rings with Large, Medium, or Small sizing. Settings previews your real menu bar as you click, and right-clicking the menu bar item changes the same options instantly. Exact percentages remain available on hover and in the panel.
Expand Down Expand Up @@ -74,7 +80,7 @@ If a live read fails, historical balances are not presented as current quota. Au

Codex metrics come from the local `codex app-server` account methods. Claude quota comes from its read-only usage endpoint, using the existing credential only in memory. This integration depends on provider behavior and is not an official provider product.

Local activity scans decode timestamps, message IDs, model identifiers, reasoning effort, and numeric counters. Prompt and response fields are not decoded, logged, or persisted. Only normalized metrics and aggregate history are saved under `~/Library/Application Support/TokenGauge`, with user-only permissions. No usage is sent to a TokenGauge server. Provider quota requests contact the provider, and update checks contact GitHub.
Local activity scans decode timestamps, message IDs, model identifiers, reasoning effort, skill names, turn durations, and numeric counters. Prompt and response fields are not decoded, logged, or persisted. Only normalized metrics and aggregate history are saved under `~/Library/Application Support/TokenGauge`, with user-only permissions. No usage is sent to a TokenGauge server. Provider quota requests contact the provider, and update checks contact GitHub.

See [SECURITY.md](SECURITY.md) for reporting and update-channel details.

Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.6.0</string>
<string>1.7.0</string>
<key>CFBundleVersion</key>
<string>9</string>
<string>10</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
40 changes: 37 additions & 3 deletions Sources/TokenGaugeApp/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppKit
import Combine
import SwiftUI
import TokenGaugeCore
import os

@MainActor
final class StatusItemController: NSObject {
Expand All @@ -19,6 +20,8 @@ final class StatusItemController: NSObject {
private var history: HistoryDashboardModel?
private var previousApp: NSRunningApplication?
private weak var trackingMenu: NSMenu?
private var closeRequested = false
private static let log = Logger(subsystem: "com.stevenacz.TokenGauge", category: "popover")

private struct PopoverGeometry: Equatable {
let sourceWindow: NSRect
Expand Down Expand Up @@ -107,7 +110,7 @@ final class StatusItemController: NSObject {
return
}
if popover.isShown {
popover.performClose(nil)
requestClose()
return
}
store.refresh()
Expand Down Expand Up @@ -171,8 +174,14 @@ final class StatusItemController: NSObject {
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]
) { [weak self] _ in
let location = NSEvent.mouseLocation
Task { @MainActor in
guard let self, self.trackingMenu == nil else { return }
guard self.popover.contentViewController?.view.window?.frame.contains(location) != true else {
Self.log.debug("outside monitor ignored a click inside the panel")
return
}
Self.log.debug("outside monitor closes")
self.dismissPopover()
}
}
Expand All @@ -183,7 +192,10 @@ final class StatusItemController: NSObject {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.dismissPopover() }
Task { @MainActor in
Self.log.debug("resign closes")
self?.dismissPopover()
}
}
}
}
Expand All @@ -204,7 +216,20 @@ final class StatusItemController: NSObject {
stopDismissMonitors()
return
}
requestClose()
}

private func requestClose() {
closeRequested = true
popover.performClose(nil)
closeRequested = false
}

private func isPanelClick(_ event: NSEvent?) -> Bool {
guard let event, [.leftMouseDown, .rightMouseDown, .otherMouseDown].contains(event.type),
let panel = popover.contentViewController?.view.window
else { return false }
return event.window === panel
}

private func updateStatusItem() {
Expand Down Expand Up @@ -330,10 +355,19 @@ extension StatusItemController {

extension StatusItemController: NSPopoverDelegate {
func popoverShouldClose(_ popover: NSPopover) -> Bool {
let event = NSApp.currentEvent
if !closeRequested, isPanelClick(event) {
Self.log.debug("close refused: click inside panel")
return false
}
guard trackingMenu != nil, let window = statusItem.button?.window else { return true }
return NSApp.currentEvent?.window !== window
return event?.window !== window
}
func popoverWillClose(_ notification: Notification) {
let event = NSApp.currentEvent
Self.log.debug(
"will close requested=\(self.closeRequested) event=\(event.map { String($0.type.rawValue) } ?? "none", privacy: .public) window=\(event?.window.map { String(describing: type(of: $0)) } ?? "none", privacy: .public)"
)
trackingMenu?.cancelTrackingWithoutAnimation()
}
func popoverDidClose(_ notification: Notification) {
Expand Down
Loading
Loading