diff --git a/AGENTS.md b/AGENTS.md
index 6a06b2e..38521df 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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//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
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bc11d9..4f5ead7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 5a4d7ab..9f04d3d 100644
--- a/README.md
+++ b/README.md
@@ -9,13 +9,18 @@
-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.
App views rendered with synthetic demonstration data.
+
+Stats: forecasts, today, models, effort, and skills
+
+
+
Classic panels, yearly history, and Settings

@@ -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.
@@ -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.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 840ec27..821b810 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.6.0
+ 1.7.0
CFBundleVersion
- 9
+ 10
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/Sources/TokenGaugeApp/App/StatusItemController.swift b/Sources/TokenGaugeApp/App/StatusItemController.swift
index 5e9a737..2824fd5 100644
--- a/Sources/TokenGaugeApp/App/StatusItemController.swift
+++ b/Sources/TokenGaugeApp/App/StatusItemController.swift
@@ -2,6 +2,7 @@ import AppKit
import Combine
import SwiftUI
import TokenGaugeCore
+import os
@MainActor
final class StatusItemController: NSObject {
@@ -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
@@ -107,7 +110,7 @@ final class StatusItemController: NSObject {
return
}
if popover.isShown {
- popover.performClose(nil)
+ requestClose()
return
}
store.refresh()
@@ -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()
}
}
@@ -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()
+ }
}
}
}
@@ -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() {
@@ -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) {
diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
index e427af0..24202d8 100644
--- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
+++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
@@ -164,7 +164,7 @@
"pace.details" = "Estimated percentage points of weekly allowance per hour, over %@ observed minutes in the last hour. This account-wide rate is not attributed to a reasoning effort.";
"pace.collecting" = "Pace: —";
"pace.insufficient" = "Requires at least three verified readings covering 30 minutes. Resets and long gaps are excluded.";
-"pace.setting" = "Show weekly use per hour";
+"pace.setting" = "Show pace and session forecast";
"history.all_efforts" = "All efforts";
"history.heading" = "Tokens";
"history.details" = "Model & effort";
@@ -210,3 +210,77 @@
"settings.right_click.show" = "Show me";
"settings.panel" = "Panel";
"settings.accounts" = "Accounts";
+
+"stats.open" = "Show stats";
+"stats.close" = "Back to panel";
+"stats.loading" = "Crunching…";
+"stats.load_failed" = "Stats unavailable";
+"stats.card.collapse" = "Hide";
+"stats.card.expand" = "Show";
+"stats.card.collapsed" = "Hidden";
+"stats.card.expanded" = "Shown";
+"stats.forecast.title" = "Will it last until the reset?";
+"stats.forecast.unavailable" = "No active limit to project.";
+"stats.forecast.help" = "The dashed line projects what you used over the last 3 days and the dotted one your last 24 h. It uses the server's real percentage, not tokens. «Max» is what you can use per day to reach the reset. Time spent on another account does not count toward the pace.";
+"stats.headline.lasts" = "You'll make it";
+"stats.headline.tight" = "Cutting it close";
+"stats.headline.runs_out" = "Won't last";
+"stats.headline.exhausted" = "Used up";
+"stats.headline.no_usage" = "No usage yet";
+"stats.verdict.lasts" = "At this pace you reach the reset with ~%@ left.";
+"stats.verdict.tight" = "You'll make it, with only ~%@ left.";
+"stats.verdict.runs_out" = "Runs out %1$@, %2$@ before the reset.";
+"stats.verdict.exhausted" = "No quota left until the reset.";
+"stats.verdict.no_usage" = "No usage in this window yet.";
+"stats.row.left" = "%@ left";
+"stats.reset_in" = "Resets %@";
+"stats.pace.average" = "3 d: %@/day";
+"stats.pace.today" = "24 h: %@/day";
+"stats.budget" = "Max %@/day";
+"session.detail_runs_out" = "At this session's pace (%1$@/h) it runs out %2$@ before the reset.";
+"session.detail" = "At this session's pace (%1$@/h) you reach the reset with ~%2$@ left.";
+"session.runs_out" = "Runs out at %@";
+"session.tight" = "Cutting it close · %@/h";
+"session.lasts" = "You'll make it · %@/h";
+"stats.verdict.runs_out_session" = "Runs out at %1$@, %2$@ before the reset.";
+"stats.budget.hour" = "Max %@/h";
+"stats.pace.hour" = "1 h: %@/h";
+"stats.pace.session" = "Session: %@/h";
+"stats.forecast.help_session" = "The dashed line projects your pace so far this session and the dotted one your last hour. It uses the server's real percentage, not tokens. «Max» is what you can use per hour to reach the reset. Time spent on another account does not count toward the pace.";
+"stats.chart.now" = "Now · %@";
+"stats.chart.reset" = "Reset";
+"stats.chart.expected" = "est. ";
+"stats.today.title" = "Today";
+"stats.today.typical" = "Usual day %@";
+"stats.today.ratio" = "%@× your usual day";
+"stats.insight.new_model" = "You're trying %@: it shows in your usage.";
+"stats.insight.spike" = "You're well above your usual day.";
+"stats.insight.quiet" = "Quiet day: below your usual usage.";
+"stats.insight.steady" = "Today's usage is within your normal range.";
+"stats.week" = "This week";
+"stats.month" = "This month";
+"stats.lifetime" = "Total";
+"stats.since" = "since %@";
+"stats.peak" = "Best day";
+"stats.streak" = "Streak";
+"stats.streak_best" = "best %@ d";
+"stats.days_value" = "%@ d";
+"stats.active_days" = "Active days";
+"stats.vs_previous" = "Compared with the same point of the previous period";
+"stats.daily.title" = "Last 14 days";
+"stats.daily.typical" = "usual day";
+"stats.other" = "Other";
+"stats.activity.title" = "Activity";
+"stats.activity.today" = "Worked today";
+"stats.activity.turns" = "%@ turns";
+"stats.activity.longest" = "Longest turn";
+"stats.activity.longest_hint" = "last 30 days";
+"stats.activity.cache" = "From cache";
+"stats.activity.cache_hint" = "of all tokens · 7 d";
+"stats.models.title" = "Models · 7 days";
+"stats.efforts.title" = "Effort · 7 days";
+"stats.skills.title" = "Top skills · 30 days";
+"stats.skills.runs" = "%@ runs";
+"stats.skills.runs_one" = "%@ run";
+"stats.chart.away" = "Other account · %1$@ → %2$@";
+"stats.chart.boost" = "Early reset: the quota went back up before the scheduled date (a provider reset or a banked reset).";
diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
index cf96c81..a26fb63 100644
--- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
+++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
@@ -164,7 +164,7 @@
"pace.details" = "Puntos porcentuales estimados de la cuota semanal por hora, con %@ minutos observados en la última hora. Este ritmo corresponde a la cuenta, no a un nivel de esfuerzo concreto.";
"pace.collecting" = "Ritmo: —";
"pace.insufficient" = "Requiere al menos tres lecturas verificadas que cubran 30 minutos. Se excluyen reinicios y huecos largos.";
-"pace.setting" = "Mostrar uso semanal por hora";
+"pace.setting" = "Mostrar ritmo y pronóstico de sesión";
"history.all_efforts" = "Todos los esfuerzos";
"history.heading" = "Tokens";
"history.details" = "Modelo y esfuerzo";
@@ -210,3 +210,77 @@
"settings.right_click.show" = "Ver cómo";
"settings.panel" = "Panel";
"settings.accounts" = "Cuentas";
+
+"stats.open" = "Ver estadísticas";
+"stats.close" = "Volver al panel";
+"stats.loading" = "Calculando…";
+"stats.load_failed" = "Estadísticas no disponibles";
+"stats.card.collapse" = "Ocultar";
+"stats.card.expand" = "Mostrar";
+"stats.card.collapsed" = "Oculta";
+"stats.card.expanded" = "Visible";
+"stats.forecast.title" = "¿Te alcanza hasta el reinicio?";
+"stats.forecast.unavailable" = "No hay un límite activo para proyectar.";
+"stats.forecast.help" = "La línea discontinua proyecta lo que gastaste en los últimos 3 días y la punteada, lo de las últimas 24 h. Usa el porcentaje real del servidor, no tokens. «Máx.» es lo que puedes gastar al día para llegar al reinicio. El tiempo que pasas en otra cuenta no cuenta para el ritmo.";
+"stats.headline.lasts" = "Te alcanza";
+"stats.headline.tight" = "Muy justo";
+"stats.headline.runs_out" = "No te alcanza";
+"stats.headline.exhausted" = "Agotado";
+"stats.headline.no_usage" = "Sin consumo aún";
+"stats.verdict.lasts" = "A este ritmo llegas al reinicio con ~%@ libre.";
+"stats.verdict.tight" = "Llegas, pero con apenas ~%@ libre.";
+"stats.verdict.runs_out" = "Se acaba el %1$@, %2$@ antes del reinicio.";
+"stats.verdict.exhausted" = "Ya no queda cupo hasta el reinicio.";
+"stats.verdict.no_usage" = "Todavía no hay consumo en esta ventana.";
+"stats.row.left" = "sobra %@";
+"stats.reset_in" = "Reinicia %@";
+"stats.pace.average" = "3 d: %@/día";
+"stats.pace.today" = "24 h: %@/día";
+"stats.budget" = "Máx. %@/día";
+"session.detail_runs_out" = "A tu ritmo de esta sesión (%1$@/h) se agota %2$@ antes del reinicio.";
+"session.detail" = "A tu ritmo de esta sesión (%1$@/h) llegas al reinicio con ~%2$@ libre.";
+"session.runs_out" = "Se agota a las %@";
+"session.tight" = "Muy justo · %@/h";
+"session.lasts" = "Te alcanza · %@/h";
+"stats.verdict.runs_out_session" = "Se acaba a las %1$@, %2$@ antes del reinicio.";
+"stats.budget.hour" = "Máx. %@/h";
+"stats.pace.hour" = "1 h: %@/h";
+"stats.pace.session" = "Sesión: %@/h";
+"stats.forecast.help_session" = "La línea discontinua proyecta tu ritmo en lo que va de la sesión y la punteada, el de la última hora. Usa el porcentaje real del servidor, no tokens. «Máx.» es lo que puedes gastar por hora para llegar al reinicio. El tiempo que pasas en otra cuenta no cuenta para el ritmo.";
+"stats.chart.now" = "Ahora · %@";
+"stats.chart.reset" = "Reinicio";
+"stats.chart.expected" = "prev. ";
+"stats.today.title" = "Hoy";
+"stats.today.typical" = "Día normal %@";
+"stats.today.ratio" = "%@× tu día normal";
+"stats.insight.new_model" = "Estrenas %@: se nota en tu consumo.";
+"stats.insight.spike" = "Hoy vas muy por encima de tu día normal.";
+"stats.insight.quiet" = "Día tranquilo: vas por debajo de lo normal.";
+"stats.insight.steady" = "Tu consumo de hoy está dentro de lo normal.";
+"stats.week" = "Esta semana";
+"stats.month" = "Este mes";
+"stats.lifetime" = "Total";
+"stats.since" = "desde %@";
+"stats.peak" = "Mejor día";
+"stats.streak" = "Racha";
+"stats.streak_best" = "mejor %@ d";
+"stats.days_value" = "%@ d";
+"stats.active_days" = "Días activos";
+"stats.vs_previous" = "Frente al mismo punto del periodo anterior";
+"stats.daily.title" = "Últimos 14 días";
+"stats.daily.typical" = "día normal";
+"stats.other" = "Otros";
+"stats.activity.title" = "Actividad";
+"stats.activity.today" = "Trabajo hoy";
+"stats.activity.turns" = "%@ turnos";
+"stats.activity.longest" = "Turno más largo";
+"stats.activity.longest_hint" = "últimos 30 días";
+"stats.activity.cache" = "Desde caché";
+"stats.activity.cache_hint" = "del total · 7 d";
+"stats.models.title" = "Modelos · 7 días";
+"stats.efforts.title" = "Esfuerzo · 7 días";
+"stats.skills.title" = "Skills más usadas · 30 días";
+"stats.skills.runs" = "%@ usos";
+"stats.skills.runs_one" = "%@ uso";
+"stats.chart.away" = "Otra cuenta · %1$@ → %2$@";
+"stats.chart.boost" = "Reinicio anticipado: el cupo subió antes de la fecha programada (lo reinició el proveedor o usaste un reinicio guardado).";
diff --git a/Sources/TokenGaugeApp/Services/AppPreferences.swift b/Sources/TokenGaugeApp/Services/AppPreferences.swift
index 2872eb1..0b30605 100644
--- a/Sources/TokenGaugeApp/Services/AppPreferences.swift
+++ b/Sources/TokenGaugeApp/Services/AppPreferences.swift
@@ -13,6 +13,7 @@ struct AppPreferences {
static let showHourlyPace = "showHourlyPace"
static let showLunaReserve = "showLunaReserve"
static let hiddenClaudeWindows = "hiddenClaudeWindows"
+ static let collapsedStatsCards = "collapsedStatsCards"
static let claudeMenuBarSource = "claudeMenuBarSource"
static let claudeMenuBarWindows = "claudeMenuBarWindows"
static let codexMenuBarWindows = "codexMenuBarWindows"
@@ -92,6 +93,11 @@ struct AppPreferences {
nonmutating set { defaults.set(newValue.map(\.rawValue).sorted(), forKey: Key.hiddenClaudeWindows) }
}
+ var collapsedStatsCards: Set {
+ get { StatsCard.decode(defaults.stringArray(forKey: Key.collapsedStatsCards)) }
+ nonmutating set { defaults.set(newValue.map(\.rawValue).sorted(), forKey: Key.collapsedStatsCards) }
+ }
+
var claudeMenuBarWindows: Set {
get {
if let stored = defaults.stringArray(forKey: Key.claudeMenuBarWindows) {
diff --git a/Sources/TokenGaugeApp/Services/StatsModel.swift b/Sources/TokenGaugeApp/Services/StatsModel.swift
new file mode 100644
index 0000000..835d64f
--- /dev/null
+++ b/Sources/TokenGaugeApp/Services/StatsModel.swift
@@ -0,0 +1,114 @@
+import Combine
+import Foundation
+import TokenGaugeCore
+
+@MainActor
+final class StatsModel: ObservableObject {
+ struct Records: Sendable {
+ var input: StatsSummary.Input
+ var quota: [HistoryQuotaRow]
+ }
+
+ @Published private(set) var records: Records?
+ @Published private(set) var loadFailed = false
+ @Published private(set) var isLoading = false
+ private var loadedRevision: Int?
+ private var summaries: [String: StatsSummary] = [:]
+ private let historyURL: URL
+ private let preview: Records?
+
+ nonisolated static let quotaLookback: TimeInterval = 8 * 86_400
+
+ init(preview: Records? = nil, historyURL: URL = UsagePaths.history()) {
+ self.preview = preview
+ self.historyURL = historyURL
+ records = preview
+ }
+
+ func summary(for providers: [UsageProvider], codexSummary: UsageSummary?) -> StatsSummary? {
+ guard var records else { return nil }
+ let key = providers.map(\.rawValue).joined(separator: "+")
+ if let cached = summaries[key] { return cached }
+ records.input.codexSummary = codexSummary
+ let value = StatsSummary.make(records.input, providers: providers)
+ summaries[key] = value
+ return value
+ }
+
+ func forecasts(for states: [(UsageProvider, [QuotaWindow])], accountFingerprint: String?, now: Date = Date())
+ -> [QuotaForecast]
+ {
+ let rows = records?.quota ?? []
+ return states.flatMap { provider, windows in
+ windows.filter { [300, 10_080].contains($0.durationMinutes) }.compactMap { window in
+ QuotaForecast.make(
+ provider: provider, window: window, rows: rows, now: now,
+ accountFingerprint: provider == .claude ? accountFingerprint : nil)
+ }
+ }
+ }
+
+ func load(revision: Int) async {
+ guard preview == nil, loadedRevision != revision || records == nil else { return }
+ isLoading = true
+ let url = historyURL
+ let now = Date()
+ do {
+ let loaded = try await Task.detached(priority: .userInitiated) {
+ try Self.read(url: url, now: now)
+ }.value
+ guard !Task.isCancelled else { return }
+ summaries.removeAll()
+ records = loaded
+ loadedRevision = revision
+ loadFailed = false
+ } catch {
+ guard !Task.isCancelled else { return }
+ loadFailed = records == nil
+ }
+ isLoading = false
+ }
+
+ static func preview(snapshots: [ProviderUsageSnapshot]) -> Records {
+ let rows = snapshots.flatMap { snapshot in
+ snapshot.dailyUsage.map {
+ HistoryTokenRow(day: $0.day, provider: snapshot.provider, model: "all", tokens: $0.tokens)
+ }
+ }
+ let today = Calendar.current.startOfDay(for: Date())
+ let start = Calendar.current.date(byAdding: .day, value: -(StatsSummary.recentDays - 1), to: today) ?? today
+ let interval = DateInterval(
+ start: start, end: Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today)
+ return Records(
+ input: StatsSummary.Input(
+ days: HistoryDashboardModel.makeDays(interval: interval, tokens: rows, efforts: []), tokens: rows,
+ efforts: []),
+ quota: [])
+ }
+
+ nonisolated static func read(url: URL, now: Date) throws -> Records {
+ let calendar = Calendar.current
+ let today = calendar.startOfDay(for: now)
+ let tokens = try UsageHistoryStore.tokenRows(at: url)
+ let efforts = try UsageHistoryStore.effortRows(at: url)
+ let firstKey = try UsageHistoryStore.bounds(at: url).firstDay
+ let recentStart = calendar.date(byAdding: .day, value: -(StatsSummary.recentDays - 1), to: today) ?? today
+ let first = firstKey.flatMap(date(from:)).map { min($0, recentStart) } ?? recentStart
+ let interval = DateInterval(start: first, end: calendar.date(byAdding: .day, value: 1, to: today) ?? now)
+ let since = HistoryDashboardModel.dayKey(
+ calendar.date(byAdding: .day, value: -StatsSummary.skillDays, to: today) ?? today)
+ return Records(
+ input: StatsSummary.Input(
+ days: HistoryDashboardModel.makeDays(interval: interval, tokens: tokens, efforts: efforts),
+ tokens: tokens, efforts: efforts,
+ activity: (try? UsageHistoryStore.activityRows(since: since, at: url)) ?? [],
+ cached: (try? UsageHistoryStore.cachedTokenRows(since: since, at: url)) ?? []),
+ quota: try UsageHistoryStore.quotaRows(since: now.addingTimeInterval(-quotaLookback), until: now, at: url))
+ }
+
+ nonisolated private static func date(from key: String) -> Date? {
+ let parts = key.split(separator: "-").compactMap { Int($0) }
+ guard parts.count == 3 else { return nil }
+ return Calendar.current.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))
+ }
+}
diff --git a/Sources/TokenGaugeApp/Services/UsageStore.swift b/Sources/TokenGaugeApp/Services/UsageStore.swift
index 1e0ba4d..f3a7db4 100644
--- a/Sources/TokenGaugeApp/Services/UsageStore.swift
+++ b/Sources/TokenGaugeApp/Services/UsageStore.swift
@@ -67,6 +67,9 @@ final class UsageStore: ObservableObject {
@Published private(set) var hiddenClaudeWindows: Set {
didSet { preferences.hiddenClaudeWindows = hiddenClaudeWindows }
}
+ @Published private(set) var collapsedStatsCards: Set {
+ didSet { preferences.collapsedStatsCards = collapsedStatsCards }
+ }
@Published var claudeMenuBarWindows: Set {
didSet { preferences.claudeMenuBarWindows = claudeMenuBarWindows }
}
@@ -153,6 +156,7 @@ final class UsageStore: ObservableObject {
animateChanges = preferences.animateChanges
showLunaReserve = preferences.showLunaReserve
hiddenClaudeWindows = preferences.hiddenClaudeWindows
+ collapsedStatsCards = preferences.collapsedStatsCards
claudeMenuBarWindows = preferences.claudeMenuBarWindows
codexMenuBarWindows = preferences.codexMenuBarWindows
hideAccountLabel = preferences.hideAccountLabel
@@ -411,6 +415,14 @@ final class UsageStore: ObservableObject {
hiddenClaudeWindows = hidden
}
+ func toggleStatsCard(_ card: StatsCard) {
+ if collapsedStatsCards.contains(card) {
+ collapsedStatsCards.remove(card)
+ } else {
+ collapsedStatsCards.insert(card)
+ }
+ }
+
func setCancelled(_ cancelled: Bool, for provider: UsageProvider) {
guard isCancelled(provider: provider) != cancelled else { return }
persistCancellation(cancelled ? Date() : nil, for: provider)
diff --git a/Sources/TokenGaugeApp/Support/ModelActivity.swift b/Sources/TokenGaugeApp/Support/ModelActivity.swift
index f3e47a1..e7e4826 100644
--- a/Sources/TokenGaugeApp/Support/ModelActivity.swift
+++ b/Sources/TokenGaugeApp/Support/ModelActivity.swift
@@ -20,14 +20,14 @@ enum ModelActivity {
) -> [ModelUsageChip] {
let scoped =
family.map { name in
- buckets.filter { displayName($0.model).caseInsensitiveCompare(name) == .orderedSame }
- } ?? buckets.filter { !excluded.contains(displayName($0.model).lowercased()) }
+ buckets.filter { Self.family($0.model).caseInsensitiveCompare(name) == .orderedSame }
+ } ?? buckets.filter { !excluded.contains(Self.family($0.model).lowercased()) }
return ModelTokenAggregator.byModel(scoped, since: since)
.prefix(limit)
.map { ModelUsageChip(model: $0.model, tokens: $0.tokens) }
}
- static func displayName(_ raw: String) -> String {
+ static func family(_ raw: String) -> String {
let identifier = raw.lowercased()
for (needle, label) in knownFamilies where identifier.contains(needle) {
return label
@@ -36,6 +36,24 @@ enum ModelActivity {
return raw
}
+ static func displayName(_ raw: String) -> String {
+ let label = family(raw)
+ let identifier = String(raw.lowercased().prefix { $0 != "[" })
+ if identifier.hasPrefix("gpt-") {
+ let parts = identifier.split(separator: "-")
+ guard parts.count > 1 else { return raw }
+ let words = parts.dropFirst(2).map { $0.prefix(1).uppercased() + $0.dropFirst() }
+ return (["GPT-" + parts[1]] + words).joined(separator: " ")
+ }
+ guard let needle = knownFamilies.first(where: { identifier.contains($0.0) })?.0,
+ let range = identifier.range(of: needle + "-")
+ else { return label }
+ let version = identifier[range.upperBound...].split(separator: "-").prefix { part in
+ part.count <= 2 && part.allSatisfy(\.isNumber)
+ }
+ return version.isEmpty ? label : label + " " + version.joined(separator: ".")
+ }
+
private static let knownFamilies: [(String, String)] = [
("fable", "Fable"),
("opus", "Opus"),
diff --git a/Sources/TokenGaugeApp/Support/StatsCard.swift b/Sources/TokenGaugeApp/Support/StatsCard.swift
new file mode 100644
index 0000000..e0adec4
--- /dev/null
+++ b/Sources/TokenGaugeApp/Support/StatsCard.swift
@@ -0,0 +1,13 @@
+enum StatsCard: String, CaseIterable {
+ case forecast
+ case today
+ case daily
+ case activity
+ case models
+ case efforts
+ case skills
+
+ static func decode(_ values: [String]?) -> Set {
+ Set((values ?? []).compactMap(StatsCard.init(rawValue:)))
+ }
+}
diff --git a/Sources/TokenGaugeApp/Support/StatsSummary.swift b/Sources/TokenGaugeApp/Support/StatsSummary.swift
new file mode 100644
index 0000000..9579144
--- /dev/null
+++ b/Sources/TokenGaugeApp/Support/StatsSummary.swift
@@ -0,0 +1,289 @@
+import Foundation
+import TokenGaugeCore
+
+struct StatsSegment: Identifiable, Equatable, Sendable {
+ let id: String
+ let tokens: Int
+ let provider: UsageProvider
+ let rank: Int
+}
+
+struct StatsDayTotal: Identifiable, Equatable, Sendable {
+ let id: String
+ let date: Date
+ let total: Int?
+ let segments: [StatsSegment]
+}
+
+struct StatsRankItem: Identifiable, Equatable, Sendable {
+ let id: String
+ let label: String
+ let value: Int
+ let provider: UsageProvider?
+}
+
+struct StatsPeak: Equatable, Sendable {
+ let date: Date
+ let tokens: Int
+}
+
+struct StatsTurns: Equatable, Sendable {
+ let todayMilliseconds: Int
+ let todayCount: Int
+ let longestMilliseconds: Int
+}
+
+enum StatsInsight: Equatable, Sendable {
+ case newModel(String)
+ case spike(Double)
+ case quiet(Double)
+ case steady(Double)
+}
+
+struct StatsSummary: Equatable, Sendable {
+ var today: Int?
+ var typicalDay: Int?
+ var week = 0
+ var previousWeek: Int?
+ var month = 0
+ var previousMonth: Int?
+ var lifetime = 0
+ var since: Date?
+ var peak: StatsPeak?
+ var activeDays = 0
+ var streak = HistoryStreak()
+ var recent: [StatsDayTotal] = []
+ var legend: [StatsSegment] = []
+ var models: [StatsRankItem] = []
+ var efforts: [StatsRankItem] = []
+ var skills: [StatsRankItem] = []
+ var turns: StatsTurns?
+ var cachedShare: Double?
+ var insight: StatsInsight?
+
+ static let recentDays = 14
+ static let typicalWindow = 28
+ static let rankingDays = 7
+ static let skillDays = 30
+
+ var ratio: Double? {
+ guard let today, let typicalDay, typicalDay > 0 else { return nil }
+ return Double(today) / Double(typicalDay)
+ }
+
+ struct Input: Sendable {
+ var days: [HistoryCalendarDay]
+ var tokens: [HistoryTokenRow]
+ var efforts: [HistoryEffortRow]
+ var activity: [HistoryActivityRow] = []
+ var cached: [HistoryCachedRow] = []
+ var codexSummary: UsageSummary?
+ }
+
+ @MainActor static func make(
+ _ input: Input, providers: [UsageProvider], now: Date = Date(), calendar base: Calendar = .current
+ ) -> StatsSummary {
+ var calendar = base
+ calendar.firstWeekday = 2
+ calendar.minimumDaysInFirstWeek = 4
+ let today = calendar.startOfDay(for: now)
+ let todayKey = HistoryDashboardModel.dayKey(today)
+ let days = input.days.filter { $0.date <= today }
+ let totals = Dictionary(uniqueKeysWithValues: days.map { ($0.id, $0.total(for: providers)) })
+ func key(_ offset: Int, from date: Date = today) -> String {
+ HistoryDashboardModel.dayKey(calendar.date(byAdding: .day, value: offset, to: date) ?? date)
+ }
+ func sum(_ keys: [String]) -> Int? {
+ let values = keys.compactMap { totals[$0] ?? nil }
+ return values.isEmpty ? nil : values.reduce(0, &+)
+ }
+
+ var summary = StatsSummary()
+ summary.today = totals[todayKey] ?? nil
+
+ let previous = (1...typicalWindow).compactMap { totals[key(-$0)] ?? nil }.filter { $0 > 0 }.sorted()
+ if previous.count >= 3 { summary.typicalDay = previous[previous.count / 2] }
+
+ let weekStart = calendar.dateInterval(of: .weekOfYear, for: today)?.start ?? today
+ let weekElapsed = (calendar.dateComponents([.day], from: weekStart, to: today).day ?? 0) + 1
+ summary.week = sum((0.. 0
+ {
+ summary.peak = StatsPeak(date: best.date, tokens: tokens)
+ }
+ if providers.contains(.codex), let codex = input.codexSummary {
+ let local = days.compactMap { $0.tokens(for: .codex) }.reduce(0, &+)
+ if let lifetime = codex.lifetimeTokens, lifetime > local { summary.lifetime += lifetime - local }
+ if providers == [.codex], let peak = codex.peakDailyTokens, peak > (summary.peak?.tokens ?? 0) {
+ summary.peak = StatsPeak(date: summary.peak?.date ?? today, tokens: peak)
+ }
+ }
+ let usageDays = Set(recorded.filter { ($0.total(for: providers) ?? 0) > 0 }.map(\.id))
+ summary.activeDays = usageDays.count
+ let streak = HistoryAnalytics.streaks(usageDays: usageDays, today: todayKey, calendar: calendar)
+ summary.streak = HistoryStreak(current: streak.current, longest: streak.longest)
+
+ let modelRows = modelTokens(input, providers: providers)
+ let rankingKeys = Set((0.. 1
+ ? recentKeys.flatMap { day in
+ providers.compactMap { provider in
+ days.first { $0.id == day }?.tokens(for: provider).map {
+ LabeledTokens(day: day, label: provider.rawValue, provider: provider, tokens: $0)
+ }
+ }
+ }
+ : modelRows.filter { recentSet.contains($0.day) }
+ let leaders = rank(segmentRows, limit: providers.count > 1 ? 2 : 3)
+ let leaderIndex = Dictionary(uniqueKeysWithValues: leaders.enumerated().map { ($1.id, $0) })
+ let fallback = providers.first ?? .claude
+ summary.legend = leaders.enumerated().map { index, item in
+ StatsSegment(id: item.id, tokens: item.value, provider: item.provider ?? fallback, rank: index)
+ }
+ summary.recent = zip(recentKeys, recentDates).map { day, date in
+ let total = totals[day] ?? nil
+ var buckets: [String: Int] = [:]
+ for row in segmentRows where row.day == day {
+ buckets[leaderIndex[row.label] != nil ? row.label : otherLabel, default: 0] += row.tokens
+ }
+ let assigned = buckets.values.reduce(0, &+)
+ if let total, total > assigned { buckets[otherLabel, default: 0] += total - assigned }
+ let segments = buckets.filter { $0.value > 0 }.map { label, tokens in
+ StatsSegment(
+ id: label, tokens: tokens, provider: leaders.first { $0.id == label }?.provider ?? fallback,
+ rank: leaderIndex[label] ?? leaders.count)
+ }.sorted { $0.rank < $1.rank }
+ return StatsDayTotal(id: day, date: date, total: total, segments: segments)
+ }
+
+ let skillKeys = Set((0.. 0 {
+ summary.cachedShare = Double(cached.map(\.cachedTokens).reduce(0, &+)) / Double(cachedTotal)
+ }
+
+ summary.insight = insight(
+ summary, modelRows: modelRows, todayKey: todayKey, firstDay: recorded.first?.id, now: now,
+ calendar: calendar)
+ return summary
+ }
+
+ static let otherLabel = "other"
+
+ private struct LabeledTokens {
+ let day: String
+ let label: String
+ let provider: UsageProvider?
+ let tokens: Int
+ }
+
+ @MainActor private static func modelTokens(_ input: Input, providers: [UsageProvider]) -> [LabeledTokens] {
+ var rows: [LabeledTokens] = []
+ if providers.contains(.claude) {
+ rows += input.tokens.filter { $0.provider == .claude && $0.model != "all" && $0.tokens > 0 }.map {
+ LabeledTokens(day: $0.day, label: $0.model, provider: .claude, tokens: $0.tokens)
+ }
+ }
+ if providers.contains(.codex) {
+ rows += input.efforts.filter { $0.provider == .codex && $0.tokens > 0 }.map {
+ LabeledTokens(day: $0.day, label: $0.model, provider: .codex, tokens: $0.tokens)
+ }
+ }
+ return rows.map {
+ LabeledTokens(day: $0.day, label: modelLabel($0.label), provider: $0.provider, tokens: $0.tokens)
+ }
+ }
+
+ private static func rank(_ rows: [LabeledTokens], limit: Int) -> [StatsRankItem] {
+ var totals: [String: (Int, UsageProvider?)] = [:]
+ for row in rows {
+ let current = totals[row.label]
+ totals[row.label] = ((current?.0 ?? 0) &+ row.tokens, current?.1 ?? row.provider)
+ }
+ let items: [StatsRankItem] = totals.compactMap { key, value in
+ value.0 > 0 ? StatsRankItem(id: key, label: key, value: value.0, provider: value.1) : nil
+ }
+ let sorted = items.sorted { lhs, rhs in
+ lhs.value == rhs.value ? lhs.label < rhs.label : lhs.value > rhs.value
+ }
+ return Array(sorted.prefix(limit))
+ }
+
+ private static func insight(
+ _ summary: StatsSummary, modelRows: [LabeledTokens], todayKey: String, firstDay: String?, now: Date,
+ calendar: Calendar
+ ) -> StatsInsight? {
+ let firstSeen = Dictionary(grouping: modelRows, by: \.label).compactMapValues { $0.map(\.day).min() }
+ let recentCutoff = HistoryDashboardModel.dayKey(calendar.date(byAdding: .day, value: -2, to: now) ?? now)
+ let historyCutoff = HistoryDashboardModel.dayKey(calendar.date(byAdding: .day, value: -9, to: now) ?? now)
+ if let firstDay, firstDay <= historyCutoff,
+ let newest = firstSeen.filter({ $0.value >= recentCutoff && $0.key != otherLabel })
+ .max(by: { $0.value == $1.value ? $0.key > $1.key : $0.value < $1.value })
+ {
+ return .newModel(newest.key)
+ }
+ guard let ratio = summary.ratio else { return nil }
+ if ratio >= 1.8 { return .spike(ratio) }
+ if ratio <= 0.4, calendar.component(.hour, from: now) >= 15 { return .quiet(ratio) }
+ return .steady(ratio)
+ }
+
+ @MainActor private static func modelLabel(_ raw: String) -> String {
+ ModelActivity.displayName(raw)
+ }
+
+ @MainActor static func effortLabel(_ raw: String) -> String {
+ switch raw {
+ case "unknown": return "history.effort_unknown".localized
+ case "xhigh": return "XHigh"
+ default: return raw.capitalized
+ }
+ }
+}
diff --git a/Sources/TokenGaugeApp/Support/UsageFormatters.swift b/Sources/TokenGaugeApp/Support/UsageFormatters.swift
index 5dde254..dda511b 100644
--- a/Sources/TokenGaugeApp/Support/UsageFormatters.swift
+++ b/Sources/TokenGaugeApp/Support/UsageFormatters.swift
@@ -10,6 +10,15 @@ enum UsageFormatters {
"\(Int(value.rounded()))%"
}
+ static func duration(_ seconds: TimeInterval) -> String {
+ let minutes = Int((seconds / 60).rounded())
+ if minutes < 1 { return "\(Int(seconds.rounded())) s" }
+ if minutes < 60 { return "\(minutes) min" }
+ let hours = minutes / 60
+ if hours < 48 { return minutes % 60 == 0 ? "\(hours) h" : "\(hours) h \(minutes % 60) min" }
+ return "\(hours / 24) d \(hours % 24) h"
+ }
+
static func tokens(_ value: Int) -> String {
if value >= 1_000_000_000 {
return "\(decimal(Double(value) / 1_000_000_000))B"
diff --git a/Sources/TokenGaugeApp/Views/CompactQuotaWindowRow.swift b/Sources/TokenGaugeApp/Views/CompactQuotaWindowRow.swift
index 2561d26..122cb11 100644
--- a/Sources/TokenGaugeApp/Views/CompactQuotaWindowRow.swift
+++ b/Sources/TokenGaugeApp/Views/CompactQuotaWindowRow.swift
@@ -9,6 +9,7 @@ struct CompactQuotaWindowRow: View {
var showPace = false
var pace: QuotaPace?
var previousPace: QuotaPace?
+ var session: QuotaForecast?
var body: some View {
HStack(alignment: .center, spacing: 14) {
@@ -37,6 +38,7 @@ struct CompactQuotaWindowRow: View {
.font(.system(size: 9))
.foregroundStyle(.secondary)
if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
+ if let session { SessionForecastLabel(forecast: session) }
}
.fixedSize(horizontal: true, vertical: false)
}
@@ -44,7 +46,7 @@ struct CompactQuotaWindowRow: View {
[UsageFormatters.windowHelp(window), UsageFormatters.modelChips(chips)].compactMap { $0 }.joined(
separator: "\n")
)
- .accessibilityElement(children: showPace ? .contain : .combine)
+ .accessibilityElement(children: showPace || session != nil ? .contain : .combine)
.accessibilityHint(UsageFormatters.modelChips(chips) ?? "")
}
}
diff --git a/Sources/TokenGaugeApp/Views/FlipCard.swift b/Sources/TokenGaugeApp/Views/FlipCard.swift
new file mode 100644
index 0000000..48c4cb2
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/FlipCard.swift
@@ -0,0 +1,80 @@
+import SwiftUI
+
+struct FlipCard: View {
+ let flipped: Bool
+ let animated: Bool
+ @ViewBuilder let front: () -> Front
+ @ViewBuilder let back: () -> Back
+ @State private var angle: Double = 0
+ @State private var target = false
+ @State private var showsFront = true
+ @State private var showsBack = false
+ @State private var frontHeight: CGFloat = 0
+
+ static var minimumBackHeight: CGFloat { 360 }
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ if showsFront {
+ front()
+ .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) {
+ $0.size.height
+ } action: { height in
+ if !showsBack { frontHeight = height }
+ }
+ .modifier(FlipFace(angle: angle, isBack: false))
+ }
+ if showsBack {
+ back()
+ .frame(height: max(frontHeight, Self.minimumBackHeight))
+ .modifier(FlipFace(angle: angle, isBack: true))
+ }
+ }
+ .onChange(of: flipped) { _, newValue in turn(to: newValue) }
+ }
+
+ private func turn(to value: Bool) {
+ target = value
+ showsFront = true
+ showsBack = true
+ let destination: Double = value ? 180 : 0
+ guard animated else {
+ angle = destination
+ settle(value)
+ return
+ }
+ withAnimation(.spring(response: 0.52, dampingFraction: 0.84), completionCriteria: .logicallyComplete) {
+ angle = destination
+ } completion: {
+ settle(value)
+ }
+ }
+
+ private func settle(_ value: Bool) {
+ guard value == target else { return }
+ showsFront = !value
+ showsBack = value
+ }
+}
+
+private struct FlipFace: ViewModifier, Animatable {
+ var angle: Double
+ let isBack: Bool
+
+ nonisolated var animatableData: Double {
+ get { angle }
+ set { angle = newValue }
+ }
+
+ func body(content: Content) -> some View {
+ let visible = isBack ? angle > 90 : angle <= 90
+ let depth = 1 - 0.07 * sin(angle * .pi / 180)
+ content
+ .rotation3DEffect(.degrees(isBack ? angle - 180 : angle), axis: (0, 1, 0), perspective: 0.32)
+ .scaleEffect(depth)
+ .opacity(visible ? 1 : 0)
+ .allowsHitTesting(visible && (angle == 0 || angle == 180))
+ .accessibilityHidden(!visible)
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/ForecastChartView.swift b/Sources/TokenGaugeApp/Views/ForecastChartView.swift
new file mode 100644
index 0000000..1470f48
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/ForecastChartView.swift
@@ -0,0 +1,360 @@
+import SwiftUI
+import TokenGaugeCore
+
+struct ForecastChartView: View {
+ let forecast: QuotaForecast
+ let tint: Color
+ let locale: Locale
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.quotaAnimationsEnabled) private var animateChanges
+ @State private var drawn: CGFloat = 0
+ @State private var projected: CGFloat = 0
+ @State private var hoverDate: Date?
+
+ static let height: CGFloat = 138
+ private let inset = EdgeInsets(top: 14, leading: 30, bottom: 18, trailing: 6)
+
+ private var animates: Bool { animateChanges && !reduceMotion }
+ private var drawProgress: CGFloat { animates ? drawn : 1 }
+ private var projectionProgress: CGFloat { animates ? projected : 1 }
+ private var danger: Bool { forecast.runsOutAt != nil || forecast.remaining <= 0 }
+ private var projectionTint: Color { danger ? .red : tint }
+
+ var body: some View {
+ GeometryReader { proxy in
+ let plot = CGRect(
+ x: inset.leading, y: inset.top, width: max(1, proxy.size.width - inset.leading - inset.trailing),
+ height: max(1, proxy.size.height - inset.top - inset.bottom))
+ ZStack(alignment: .topLeading) {
+ grid(plot)
+ awayBands(plot)
+ area(plot)
+ .fill(
+ LinearGradient(
+ colors: [tint.opacity(0.28), tint.opacity(0.02)], startPoint: .top, endPoint: .bottom)
+ )
+ .opacity(Double(drawProgress))
+ line(plot).trim(from: 0, to: drawProgress)
+ .stroke(tint, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
+ if let recent = recentProjection(plot) {
+ recent.trim(from: 0, to: projectionProgress)
+ .stroke(
+ Color.secondary.opacity(0.55),
+ style: StrokeStyle(lineWidth: 1.2, lineCap: .round, dash: [1.5, 3.5]))
+ }
+ projection(plot).trim(from: 0, to: projectionProgress)
+ .stroke(
+ projectionTint.opacity(0.9), style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [5, 4]))
+ markers(plot)
+ if let hoverDate { hoverOverlay(plot, date: hoverDate) }
+ }
+ .contentShape(Rectangle())
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(let location):
+ let fraction = min(max((location.x - plot.minX) / plot.width, 0), 1)
+ hoverDate = forecast.start.addingTimeInterval(Double(fraction) * span)
+ case .ended:
+ hoverDate = nil
+ }
+ }
+ }
+ .frame(height: Self.height)
+ .onAppear(perform: play)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(accessibilitySummary)
+ }
+
+ private var span: TimeInterval { max(1, forecast.reset.timeIntervalSince(forecast.start)) }
+
+ private func x(_ date: Date, _ plot: CGRect) -> CGFloat {
+ plot.minX + CGFloat(min(max(date.timeIntervalSince(forecast.start) / span, 0), 1)) * plot.width
+ }
+
+ private func y(_ remaining: Double, _ plot: CGRect) -> CGFloat {
+ plot.maxY - CGFloat(min(max(remaining, 0), 100) / 100) * plot.height
+ }
+
+ private func play() {
+ guard animates else {
+ drawn = 1
+ projected = 1
+ return
+ }
+ drawn = 0
+ projected = 0
+ withAnimation(.easeOut(duration: 0.75)) { drawn = 1 }
+ withAnimation(.easeInOut(duration: 0.5).delay(0.6)) { projected = 1 }
+ }
+
+ private var segments: [[ForecastPoint]] {
+ let starts = Set(forecast.boosts.map(\.date))
+ var result: [[ForecastPoint]] = []
+ for point in forecast.points {
+ if let last = result.last?.last,
+ !starts.contains(point.date) && !QuotaForecast.crosses(forecast.away, last, point)
+ {
+ result[result.count - 1].append(point)
+ } else {
+ result.append([point])
+ }
+ }
+ return result
+ }
+
+ private func awayInterval(at date: Date) -> DateInterval? {
+ forecast.away.first { $0.start <= date && date <= $0.end && $0.end > forecast.start }
+ }
+
+ private func awayLabel(_ interval: DateInterval) -> String {
+ let left = forecast.points.last { $0.date <= interval.start }?.remaining
+ let back = forecast.points.first { $0.date >= interval.end }?.remaining
+ return "stats.chart.away".localized(
+ left.map(UsageFormatters.percentage) ?? "—", back.map(UsageFormatters.percentage) ?? "—")
+ }
+
+ private func awayBands(_ plot: CGRect) -> some View {
+ ZStack(alignment: .topLeading) {
+ ForEach(forecast.away.filter { $0.end > forecast.start }, id: \.start) { interval in
+ let minX = x(interval.start, plot)
+ let width = x(interval.end, plot) - minX
+ RoundedRectangle(cornerRadius: 3)
+ .fill(Color.primary.opacity(0.06))
+ .frame(width: max(1, width), height: plot.height)
+ .position(x: minX + width / 2, y: plot.midY)
+ if width >= 16 {
+ Image(systemName: "person.2.fill")
+ .font(.system(size: 8))
+ .foregroundStyle(.tertiary)
+ .position(x: minX + width / 2, y: plot.minY + 9)
+ .accessibilityHidden(true)
+ }
+ }
+ }
+ .opacity(Double(drawProgress))
+ .allowsHitTesting(false)
+ }
+
+ private func line(_ plot: CGRect) -> Path {
+ Path { path in
+ for segment in segments {
+ for (index, point) in segment.enumerated() {
+ let location = CGPoint(x: x(point.date, plot), y: y(point.remaining, plot))
+ if index == 0 { path.move(to: location) } else { path.addLine(to: location) }
+ }
+ }
+ }
+ }
+
+ private func area(_ plot: CGRect) -> Path {
+ Path { path in
+ for segment in segments {
+ guard let first = segment.first, let last = segment.last else { continue }
+ path.move(to: CGPoint(x: x(first.date, plot), y: plot.maxY))
+ for point in segment {
+ path.addLine(to: CGPoint(x: x(point.date, plot), y: y(point.remaining, plot)))
+ }
+ path.addLine(to: CGPoint(x: x(last.date, plot), y: plot.maxY))
+ path.closeSubpath()
+ }
+ }
+ }
+
+ private func projectionPath(pace: Double?, runsOut: Date?, _ plot: CGRect) -> Path? {
+ guard let pace, forecast.remaining > 0 else { return nil }
+ let start = CGPoint(x: x(forecast.now, plot), y: y(forecast.remaining, plot))
+ return Path { path in
+ path.move(to: start)
+ if let runsOut {
+ path.addLine(to: CGPoint(x: x(runsOut, plot), y: plot.maxY))
+ path.addLine(to: CGPoint(x: plot.maxX, y: plot.maxY))
+ } else {
+ path.addLine(
+ to: CGPoint(x: plot.maxX, y: y(forecast.remaining - pace * forecast.hoursLeft, plot)))
+ }
+ }
+ }
+
+ private func projection(_ plot: CGRect) -> Path {
+ projectionPath(pace: forecast.pointsPerHour, runsOut: forecast.runsOutAt, plot) ?? Path()
+ }
+
+ private func recentProjection(_ plot: CGRect) -> Path? {
+ guard let recent = forecast.recentPointsPerHour, let average = forecast.pointsPerHour,
+ abs(recent - average) * forecast.hoursLeft >= 5
+ else { return nil }
+ return projectionPath(pace: recent, runsOut: forecast.recentRunsOutAt, plot)
+ }
+
+ private func grid(_ plot: CGRect) -> some View {
+ ZStack(alignment: .topLeading) {
+ ForEach([0.0, 50.0, 100.0], id: \.self) { value in
+ Path { path in
+ path.move(to: CGPoint(x: plot.minX, y: y(value, plot)))
+ path.addLine(to: CGPoint(x: plot.maxX, y: y(value, plot)))
+ }
+ .stroke(Color.primary.opacity(value == 0 ? 0.14 : 0.07), lineWidth: 1)
+ Text("\(Int(value))%")
+ .font(.system(size: 8, weight: .medium)).monospacedDigit()
+ .foregroundStyle(.tertiary)
+ .frame(width: plot.minX - 5, alignment: .trailing)
+ .position(x: (plot.minX - 5) / 2, y: y(value, plot))
+ }
+ ForEach(forecast.isSession ? hourTicks(plot) : dayTicks(plot), id: \.date) { tick in
+ Text(tick.label)
+ .font(.system(size: 8, weight: tick.isCurrent ? .semibold : .regular))
+ .foregroundStyle(tick.isCurrent ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
+ .position(x: tick.x, y: plot.maxY + 10)
+ }
+ }
+ }
+
+ private struct AxisTick {
+ let date: Date
+ let x: CGFloat
+ let label: String
+ let isCurrent: Bool
+ }
+
+ private func hourTicks(_ plot: CGRect) -> [AxisTick] {
+ let calendar = Calendar.current
+ let current = calendar.dateInterval(of: .hour, for: forecast.now)?.start
+ var hour = calendar.dateInterval(of: .hour, for: forecast.start)?.end ?? forecast.reset
+ var ticks: [AxisTick] = []
+ while hour < forecast.reset {
+ if hour.timeIntervalSince(forecast.start) >= 600, forecast.reset.timeIntervalSince(hour) >= 600 {
+ ticks.append(
+ AxisTick(
+ date: hour, x: x(hour, plot), label: hour.formatted(.dateTime.hour().locale(locale)),
+ isCurrent: hour == current))
+ }
+ hour = calendar.date(byAdding: .hour, value: 1, to: hour) ?? forecast.reset
+ }
+ return ticks
+ }
+
+ private func dayTicks(_ plot: CGRect) -> [AxisTick] {
+ let calendar = Calendar.current
+ var ticks: [AxisTick] = []
+ var day = calendar.startOfDay(for: forecast.start)
+ let today = calendar.startOfDay(for: forecast.now)
+ while day < forecast.reset {
+ let next = calendar.date(byAdding: .day, value: 1, to: day) ?? forecast.reset
+ let middle = Date(
+ timeIntervalSince1970: (max(day, forecast.start).timeIntervalSince1970
+ + min(next, forecast.reset).timeIntervalSince1970) / 2)
+ if min(next, forecast.reset).timeIntervalSince(max(day, forecast.start)) >= 6 * 3600 {
+ ticks.append(
+ AxisTick(
+ date: day, x: x(middle, plot),
+ label: day.formatted(.dateTime.weekday(.narrow).locale(locale)),
+ isCurrent: day == today))
+ }
+ day = next
+ }
+ return ticks
+ }
+
+ private func markers(_ plot: CGRect) -> some View {
+ let nowX = x(forecast.now, plot)
+ let nowY = y(forecast.remaining, plot)
+ return ZStack(alignment: .topLeading) {
+ Path { path in
+ path.move(to: CGPoint(x: nowX, y: plot.minY - 6))
+ path.addLine(to: CGPoint(x: nowX, y: plot.maxY))
+ }
+ .stroke(Color.primary.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [2, 3]))
+ Path { path in
+ path.move(to: CGPoint(x: plot.maxX, y: plot.minY - 6))
+ path.addLine(to: CGPoint(x: plot.maxX, y: plot.maxY))
+ }
+ .stroke(tint.opacity(0.45), style: StrokeStyle(lineWidth: 1, dash: [2, 3]))
+ Text("stats.chart.now".localized(UsageFormatters.percentage(forecast.remaining)))
+ .font(.system(size: 8, weight: .semibold)).monospacedDigit()
+ .foregroundStyle(.secondary)
+ .fixedSize()
+ .position(x: min(max(nowX, plot.minX + 26), plot.maxX - 60), y: plot.minY - 8)
+ Text("stats.chart.reset".localized)
+ .font(.system(size: 8, weight: .semibold))
+ .foregroundStyle(tint.opacity(0.9))
+ .fixedSize()
+ .position(x: plot.maxX - 18, y: plot.minY - 8)
+ .opacity(nowX < plot.maxX - 100 ? 1 : 0)
+ Circle()
+ .fill(Color.white)
+ .overlay(Circle().stroke(tint, lineWidth: 2.5))
+ .frame(width: 9, height: 9)
+ .position(x: nowX, y: nowY)
+ .scaleEffect(drawProgress >= 1 ? 1 : 0.01, anchor: .center)
+ .animation(animates ? .spring(response: 0.35, dampingFraction: 0.6) : nil, value: drawProgress >= 1)
+ ForEach(forecast.boosts, id: \.date) { boost in
+ Image(systemName: "arrow.up")
+ .font(.system(size: 7, weight: .black))
+ .foregroundStyle(.white)
+ .frame(width: 13, height: 13)
+ .background(Circle().fill(Color.green))
+ .position(x: x(boost.date, plot), y: plot.maxY)
+ .opacity(Double(drawProgress))
+ .help("stats.chart.boost".localized)
+ }
+ if let runsOut = forecast.runsOutAt {
+ Image(systemName: "xmark")
+ .font(.system(size: 7, weight: .black))
+ .foregroundStyle(.white)
+ .frame(width: 13, height: 13)
+ .background(Circle().fill(Color.red))
+ .position(x: x(runsOut, plot), y: plot.maxY)
+ .opacity(Double(projectionProgress))
+ }
+ }
+ }
+
+ private func hoverOverlay(_ plot: CGRect, date: Date) -> some View {
+ let isFuture = date > forecast.now
+ let away = awayInterval(at: date)
+ let value: Double? =
+ away != nil
+ ? nil
+ : isFuture
+ ? forecast.pointsPerHour.map {
+ max(0, forecast.remaining - $0 * date.timeIntervalSince(forecast.now) / 3600)
+ }
+ : QuotaForecast.interpolatedRemaining(forecast.points, at: date)
+ let px = x(date, plot)
+ let label =
+ date.formatted(
+ forecast.isSession
+ ? .dateTime.hour().minute().locale(locale)
+ : .dateTime.weekday(.abbreviated).hour().minute().locale(locale)) + " · "
+ + (away.map(awayLabel)
+ ?? value.map { (isFuture ? "stats.chart.expected".localized : "") + UsageFormatters.percentage($0) }
+ ?? "—")
+ return ZStack(alignment: .topLeading) {
+ Path { path in
+ path.move(to: CGPoint(x: px, y: plot.minY))
+ path.addLine(to: CGPoint(x: px, y: plot.maxY))
+ }
+ .stroke(Color.primary.opacity(0.35), lineWidth: 1)
+ if let value {
+ Circle().fill(isFuture ? projectionTint : tint).frame(width: 6, height: 6)
+ .position(x: px, y: y(value, plot))
+ }
+ Text(label)
+ .font(.system(size: 9, weight: .medium)).monospacedDigit()
+ .padding(.horizontal, 6).padding(.vertical, 3)
+ .background(
+ RoundedRectangle(cornerRadius: 5).fill(Theme.panelBackground)
+ .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.primary.opacity(0.12)))
+ )
+ .fixedSize()
+ .position(x: min(max(px, plot.minX + 55), plot.maxX - 55), y: plot.minY + 10)
+ }
+ .allowsHitTesting(false)
+ }
+
+ private var accessibilitySummary: String {
+ let now = UsageFormatters.percentage(forecast.remaining)
+ guard let projected = forecast.projectedRemainingAtReset else { return now }
+ return now + " · " + "stats.chart.expected".localized + UsageFormatters.percentage(max(0, projected))
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/HistoryModeControl.swift b/Sources/TokenGaugeApp/Views/HistoryModeControl.swift
index 565496e..194774b 100644
--- a/Sources/TokenGaugeApp/Views/HistoryModeControl.swift
+++ b/Sources/TokenGaugeApp/Views/HistoryModeControl.swift
@@ -1,40 +1,120 @@
-import AppKit
import SwiftUI
-struct HistoryModeControl: NSViewRepresentable {
+struct SegmentChrome: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .padding(2)
+ .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.primary.opacity(0.05)))
+ }
+}
+
+struct SegmentFill: View {
+ let selected: Bool
+ let hovered: Bool
+
+ var body: some View {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.primary.opacity(selected ? 0.11 : hovered ? 0.05 : 0))
+ .shadow(color: .black.opacity(selected ? 0.12 : 0), radius: 1.5, y: 0.5)
+ }
+}
+
+struct HistoryModeControl: View {
@Binding var mode: HistoryMode
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.quotaAnimationsEnabled) private var animateChanges
+ @State private var hovered: HistoryMode?
- func makeCoordinator() -> Coordinator { Coordinator(self) }
-
- func makeNSView(context: Context) -> NSSegmentedControl {
- let control = NSSegmentedControl(
- labels: HistoryMode.allCases.map { $0.titleKey.localized },
- trackingMode: .selectOne, target: context.coordinator, action: #selector(Coordinator.select(_:)))
- HistoryControlAppearance.apply(to: control)
- control.segmentDistribution = .fillEqually
- control.selectedSegment = HistoryMode.allCases.firstIndex(of: mode) ?? 0
- control.setAccessibilityIdentifier("TokenGauge.historyMode.control")
- control.setAccessibilityLabel("history.view".localized)
- return control
+ var body: some View {
+ HStack(spacing: 2) {
+ ForEach(HistoryMode.allCases) { item in
+ let selected = item == mode
+ Button {
+ guard mode != item else { return }
+ withAnimation(animateChanges && !reduceMotion ? .easeOut(duration: 0.16) : nil) { mode = item }
+ } label: {
+ Text(item.titleKey.localized)
+ .font(.system(size: 10.5, weight: selected ? .semibold : .medium))
+ .lineLimit(1)
+ .fixedSize()
+ .padding(.horizontal, 8)
+ .frame(height: Theme.Layout.historyModeSegmentHeight)
+ .background(SegmentFill(selected: selected, hovered: hovered == item))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(selected ? .primary : .secondary)
+ .onHover { inside in
+ if inside { hovered = item } else if hovered == item { hovered = nil }
+ }
+ .accessibilityIdentifier("TokenGauge.historyMode.\(item.rawValue)")
+ .accessibilityAddTraits(selected ? [.isSelected] : [])
+ }
+ }
+ .modifier(SegmentChrome())
+ .fixedSize()
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("TokenGauge.historyMode.control")
+ .accessibilityLabel("history.view".localized)
}
+}
- func updateNSView(_ control: NSSegmentedControl, context: Context) {
- context.coordinator.parent = self
- for (index, item) in HistoryMode.allCases.enumerated() {
- let label = item.titleKey.localized
- if control.label(forSegment: index) != label { control.setLabel(label, forSegment: index) }
+struct HistoryPeriodControl: View {
+ let title: String
+ let canGoBack: Bool
+ let canGoForward: Bool
+ let onPrevious: () -> Void
+ let onToday: () -> Void
+ let onNext: () -> Void
+ @State private var hovered: Int?
+
+ var body: some View {
+ HStack(spacing: 0) {
+ chevron("chevron.left", index: 0, enabled: canGoBack, key: "history.previous", action: onPrevious)
+ divider
+ Button(action: onToday) {
+ Text(title)
+ .font(.system(size: 10.5, weight: .semibold)).monospacedDigit()
+ .lineLimit(1)
+ .fixedSize()
+ .contentTransition(.numericText())
+ .padding(.horizontal, 8)
+ .frame(height: Theme.Layout.historyModeSegmentHeight)
+ .background(SegmentFill(selected: false, hovered: hovered == 1))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .onHover { hovered = $0 ? 1 : (hovered == 1 ? nil : hovered) }
+ .help("history.return_today".localized)
+ divider
+ chevron("chevron.right", index: 2, enabled: canGoForward, key: "history.next", action: onNext)
}
- let selected = HistoryMode.allCases.firstIndex(of: mode) ?? 0
- if control.selectedSegment != selected { control.selectedSegment = selected }
+ .modifier(SegmentChrome())
+ .fixedSize()
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("TokenGauge.historyPeriod.control")
+ .accessibilityLabel("history.period".localized)
}
- @MainActor final class Coordinator: NSObject {
- var parent: HistoryModeControl
- init(_ parent: HistoryModeControl) { self.parent = parent }
+ private var divider: some View {
+ Rectangle().fill(Color.primary.opacity(0.1)).frame(width: 1, height: 11)
+ }
- @objc func select(_ control: NSSegmentedControl) {
- guard HistoryMode.allCases.indices.contains(control.selectedSegment) else { return }
- parent.mode = HistoryMode.allCases[control.selectedSegment]
+ private func chevron(_ symbol: String, index: Int, enabled: Bool, key: String, action: @escaping () -> Void)
+ -> some View
+ {
+ Button(action: action) {
+ Image(systemName: symbol)
+ .font(.system(size: 9, weight: .semibold))
+ .frame(width: 19, height: Theme.Layout.historyModeSegmentHeight)
+ .background(SegmentFill(selected: false, hovered: enabled && hovered == index))
+ .contentShape(Rectangle())
}
+ .buttonStyle(.plain)
+ .foregroundStyle(enabled ? .secondary : .quaternary)
+ .disabled(!enabled)
+ .onHover { hovered = $0 ? index : (hovered == index ? nil : hovered) }
+ .help(key.localized)
+ .accessibilityLabel(key.localized)
}
}
diff --git a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
index c527077..3b94460 100644
--- a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
+++ b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
@@ -70,10 +70,7 @@ struct HistoryPanelView: View {
VStack(alignment: .leading, spacing: 6) {
ViewThatFits(in: .horizontal) {
historyToolbar(showsStreak: true)
- VStack(alignment: .leading, spacing: 3) {
- historyToolbar(showsStreak: false)
- streakBadge
- }
+ historyToolbar(showsStreak: false)
}
if model.loadFailed {
Text("history.load_failed".localized).font(.caption).foregroundStyle(.secondary)
@@ -90,7 +87,6 @@ struct HistoryPanelView: View {
}
.animation(motion, value: model.loadedMode)
.animation(motion, value: model.periodStart)
- resetLegend
selectionSummary
}
}
@@ -120,31 +116,37 @@ struct HistoryPanelView: View {
}
private func historyToolbar(showsStreak: Bool) -> some View {
- HStack(spacing: 8) {
+ HStack(spacing: 0) {
if mode == .recent {
Text("history.heading".localized).font(.system(size: 11, weight: .semibold))
.fixedSize()
} else {
navigation
}
- Spacer(minLength: 0)
+ Spacer(minLength: 6)
if showsStreak {
streakBadge
- Spacer(minLength: 0)
+ Spacer(minLength: 6)
}
modePicker
}
}
private var streakBadge: some View {
- HStack(spacing: 4) {
- Image(systemName: "flame.fill").foregroundStyle(accent)
- Text(streakLine).foregroundStyle(.secondary)
+ let streak = model.streak(for: providers)
+ return HStack(spacing: 3) {
+ Image(systemName: "flame.fill").foregroundStyle(streak.current > 0 ? accent : .secondary)
+ .symbolEffect(.bounce, value: streak.current)
+ Text("stats.days_value".localized(String(streak.current)))
+ .foregroundStyle(.primary.opacity(0.85)).monospacedDigit()
+ .contentTransition(.numericText())
}
- .font(.system(size: 9, weight: .medium))
- .padding(.horizontal, 7).padding(.vertical, 3)
- .background(Capsule().fill(Color.primary.opacity(0.045)))
+ .font(.system(size: 10.5, weight: .semibold))
+ .padding(.horizontal, 8)
+ .frame(height: Theme.Layout.historyModeSegmentHeight + 4)
+ .background(Capsule().fill(accent.opacity(streak.current > 0 ? 0.1 : 0.04)))
.fixedSize()
+ .help(streakLine)
.accessibilityElement(children: .ignore).accessibilityLabel(streakLine)
}
@@ -189,9 +191,6 @@ struct HistoryPanelView: View {
private var modePicker: some View {
HistoryModeControl(mode: $mode)
- .frame(
- width: Theme.Layout.historyModeSegmentWidth * 3 + 4,
- height: Theme.Layout.historyModeSegmentHeight + 4)
}
private var todayBadge: some View {
@@ -205,14 +204,12 @@ struct HistoryPanelView: View {
}
private var navigation: some View {
- let titleWidth: CGFloat = mode == .calendar ? 52 : 90
- return HistoryPeriodControl(
- title: periodLabel, titleWidth: titleWidth,
+ HistoryPeriodControl(
+ title: periodLabel,
canGoBack: model.canGoBack && !model.isLoading,
canGoForward: model.canGoForward && !model.isLoading,
onPrevious: { model.move(-1) }, onToday: returnToToday, onNext: { model.move(1) }
)
- .frame(width: titleWidth + 48, height: Theme.Layout.historyModeSegmentHeight + 4)
}
private func returnToToday() {
@@ -224,8 +221,12 @@ struct HistoryPanelView: View {
private var periodLabel: String {
if mode == .calendar { return model.periodStart.formatted(.dateTime.year().locale(locale)) }
let last = Calendar.current.date(byAdding: .day, value: -1, to: model.periodEnd) ?? model.periodEnd
- return model.periodStart.formatted(.dateTime.month(.abbreviated).day().locale(locale))
- + " – " + last.formatted(.dateTime.month(.abbreviated).day().locale(locale))
+ guard Calendar.current.isDate(model.periodStart, equalTo: last, toGranularity: .month) else {
+ return model.periodStart.formatted(.dateTime.day().month(.abbreviated).locale(locale))
+ + "–" + last.formatted(.dateTime.day().month(.abbreviated).locale(locale))
+ }
+ return model.periodStart.formatted(.dateTime.day().locale(locale)) + "–"
+ + last.formatted(.dateTime.day().month(.abbreviated).locale(locale))
}
private var bars: some View {
@@ -351,34 +352,6 @@ struct HistoryPanelView: View {
resets.filter { Calendar.current.isDate($0.date, inSameDayAs: day.date) }
}
- @ViewBuilder private var resetLegend: some View {
- if !resets.isEmpty {
- VStack(alignment: .leading, spacing: 3) {
- Text("history.weekly_reset".localized)
- .font(.system(size: 9, weight: .medium)).foregroundStyle(.secondary)
- HStack(alignment: .top, spacing: 8) {
- ForEach(providers, id: \.self) { provider in
- if let reset = resets.first(where: { $0.provider == provider }) {
- HStack(spacing: 4) {
- RoundedRectangle(cornerRadius: 1).stroke(color(provider), lineWidth: 1.5)
- .frame(width: 6, height: 6)
- Text(
- name(provider) + " · "
- + reset.date.formatted(
- .dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()
- .locale(locale))
- )
- .font(.system(size: 9)).lineLimit(2)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .help("history.projection_help".localized)
- }
- }
- }
- }
- }
- }
-
private func weekdayLabel(_ weekday: Int) -> String {
var calendar = Calendar.current
calendar.locale = locale
diff --git a/Sources/TokenGaugeApp/Views/HistoryPeriodControl.swift b/Sources/TokenGaugeApp/Views/HistoryPeriodControl.swift
deleted file mode 100644
index 9e4f545..0000000
--- a/Sources/TokenGaugeApp/Views/HistoryPeriodControl.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-import AppKit
-import SwiftUI
-
-@MainActor
-enum HistoryControlAppearance {
- static func apply(to control: NSSegmentedControl) {
- control.segmentStyle = .rounded
- control.controlSize = .small
- control.font = .systemFont(ofSize: 10)
- control.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- }
-}
-
-struct HistoryPeriodControl: NSViewRepresentable {
- let title: String
- let titleWidth: CGFloat
- let canGoBack: Bool
- let canGoForward: Bool
- let onPrevious: () -> Void
- let onToday: () -> Void
- let onNext: () -> Void
-
- func makeCoordinator() -> Coordinator { Coordinator(self) }
-
- func makeNSView(context: Context) -> NSSegmentedControl {
- let control = NSSegmentedControl(
- labels: ["", title, ""], trackingMode: .momentary,
- target: context.coordinator, action: #selector(Coordinator.select(_:)))
- HistoryControlAppearance.apply(to: control)
- control.segmentDistribution = .fill
- control.setWidth(22, forSegment: 0)
- control.setWidth(titleWidth, forSegment: 1)
- control.setWidth(22, forSegment: 2)
- for (index, symbol, key) in [(0, "chevron.left", "history.previous"), (2, "chevron.right", "history.next")] {
- let image = NSImage(systemSymbolName: symbol, accessibilityDescription: key.localized)
- image?.size = NSSize(width: 8, height: 10)
- control.setImage(image, forSegment: index)
- control.setImageScaling(.scaleProportionallyDown, forSegment: index)
- control.setToolTip(key.localized, forSegment: index)
- }
- control.setToolTip("history.return_today".localized, forSegment: 1)
- control.setAccessibilityIdentifier("TokenGauge.historyPeriod.control")
- control.setAccessibilityLabel("history.period".localized)
- update(control)
- return control
- }
-
- func updateNSView(_ control: NSSegmentedControl, context: Context) {
- context.coordinator.parent = self
- update(control)
- }
-
- private func update(_ control: NSSegmentedControl) {
- if control.label(forSegment: 1) != title { control.setLabel(title, forSegment: 1) }
- if control.width(forSegment: 1) != titleWidth { control.setWidth(titleWidth, forSegment: 1) }
- control.setEnabled(canGoBack, forSegment: 0)
- control.setEnabled(canGoForward, forSegment: 2)
- }
-
- @MainActor final class Coordinator: NSObject {
- var parent: HistoryPeriodControl
- init(_ parent: HistoryPeriodControl) { self.parent = parent }
-
- @objc func select(_ control: NSSegmentedControl) {
- switch control.selectedSegment {
- case 0: if parent.canGoBack { parent.onPrevious() }
- case 1: parent.onToday()
- case 2: if parent.canGoForward { parent.onNext() }
- default: break
- }
- }
- }
-}
diff --git a/Sources/TokenGaugeApp/Views/PopoverView.swift b/Sources/TokenGaugeApp/Views/PopoverView.swift
index 3da5776..67b813e 100644
--- a/Sources/TokenGaugeApp/Views/PopoverView.swift
+++ b/Sources/TokenGaugeApp/Views/PopoverView.swift
@@ -10,6 +10,8 @@ struct PopoverView: View {
@ObservedObject private var updates = UpdateManager.shared
@ObservedObject private var localization = LocalizationManager.shared
@StateObject private var history: HistoryDashboardModel
+ @StateObject private var stats: StatsModel
+ @State private var showsStats = false
init(
store: UsageStore, showSettings: @escaping () -> Void, showAbout: @escaping () -> Void,
@@ -21,8 +23,11 @@ struct PopoverView: View {
let preview = store.historyReadsEnabled ? nil : [store.claude.snapshot, store.codex.snapshot].compactMap { $0 }
_history = StateObject(
wrappedValue: history ?? HistoryDashboardModel(previewSnapshots: preview, mode: store.historyMode))
+ _stats = StateObject(wrappedValue: StatsModel(preview: preview.map(StatsModel.preview(snapshots:))))
}
+ private var animatesMotion: Bool { store.animateChanges && !reduceMotion }
+
var providerMaxHeight: CGFloat {
store.panelStyle == .rings
? Theme.Layout.ringProviderMaxHeight
@@ -66,7 +71,24 @@ struct PopoverView: View {
UpdateBannerView(updates: updates)
}
providerPicker
+ FlipCard(flipped: showsStats, animated: animatesMotion) {
+ panelFace
+ } back: {
+ StatsView(store: store, model: stats, providers: store.displayMode.providers)
+ .task(id: store.historyRevision) { await stats.load(revision: store.historyRevision) }
+ }
+ }
+ .padding(.horizontal, Theme.Layout.panelPadding)
+ .padding(.top, 12)
+ .padding(.bottom, Theme.Layout.panelBottomPadding)
+ .frame(width: panelWidth)
+ .environment(\.quotaAnimationsEnabled, store.animateChanges)
+ .fixedSize(horizontal: false, vertical: true)
+ .id(localization.language)
+ }
+ private var panelFace: some View {
+ VStack(alignment: .leading, spacing: Theme.Layout.sectionSpacing) {
ViewThatFits(in: .vertical) {
providerContent
ScrollView {
@@ -76,18 +98,28 @@ struct PopoverView: View {
.frame(height: providerMaxHeight)
}
.frame(maxHeight: providerMaxHeight)
- .animation(store.animateChanges && !reduceMotion ? Theme.Motion.content : nil, value: store.panelStyle)
+ .animation(animatesMotion ? Theme.Motion.content : nil, value: store.panelStyle)
HistoryPanelView(
model: history, mode: $store.historyMode,
providers: store.displayMode.providers, resets: upcomingResets, compact: store.panelStyle != .standard)
}
- .padding(.horizontal, Theme.Layout.panelPadding)
- .padding(.top, 12)
- .padding(.bottom, Theme.Layout.panelBottomPadding)
- .frame(width: panelWidth)
- .environment(\.quotaAnimationsEnabled, store.animateChanges)
- .fixedSize(horizontal: false, vertical: true)
- .id(localization.language)
+ }
+
+ private var statsButton: some View {
+ Button {
+ showsStats.toggle()
+ } label: {
+ Image(systemName: showsStats ? "gauge.with.dots.needle.67percent" : "chart.xyaxis.line")
+ .font(.system(size: 12, weight: showsStats ? .semibold : .regular))
+ .foregroundStyle(showsStats ? AnyShapeStyle(Theme.claude) : AnyShapeStyle(.secondary))
+ .contentTransition(.symbolEffect(.replace))
+ .frame(width: 18, height: 16)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help((showsStats ? "stats.close" : "stats.open").localized)
+ .accessibilityLabel((showsStats ? "stats.close" : "stats.open").localized)
+ .accessibilityIdentifier("TokenGauge.stats")
}
var upcomingResets: [HistoryReset] {
@@ -159,7 +191,8 @@ struct PopoverView: View {
hidesAccountLabel: store.hideAccountLabel,
claudeAutomaticRecovery: store.claudeAutomaticRecovery,
showHourlyPace: store.showHourlyPace,
- stretchesHeight: store.displayMode == .unified && store.panelStyle == .rings,
+ showsSessionForecast: store.showHourlyPace && store.displayMode != .unified,
+ stretchesHeight: store.displayMode == .unified && store.panelStyle != .compact,
accountLabel: store.claudeAccountLabel,
paces: paces(for: provider), previousPaces: previousPaces(for: provider)
)
@@ -235,6 +268,8 @@ struct PopoverView: View {
Spacer(minLength: 0)
+ statsButton
+
Menu {
Picker("settings.panel_style".localized, selection: $store.panelStyle) {
ForEach(QuotaPanelStyle.allCases) { style in
diff --git a/Sources/TokenGaugeApp/Views/ProviderCard.swift b/Sources/TokenGaugeApp/Views/ProviderCard.swift
index 99216ef..25c7091 100644
--- a/Sources/TokenGaugeApp/Views/ProviderCard.swift
+++ b/Sources/TokenGaugeApp/Views/ProviderCard.swift
@@ -13,6 +13,7 @@ struct ProviderCard: View {
var hidesAccountLabel = false
var claudeAutomaticRecovery = false
var showHourlyPace = false
+ var showsSessionForecast = false
var stretchesHeight = false
var accountLabel: String?
var paces: [String: QuotaPace] = [:]
@@ -76,6 +77,7 @@ struct ProviderCard: View {
QuotaRingWindow(
window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
+ session: sessionForecast(window),
metrics: .single, alignsTitleRows: false)
} else {
ViewThatFits(in: .horizontal) {
@@ -89,7 +91,8 @@ struct ProviderCard: View {
if panelStyle == .compact {
CompactQuotaWindowRow(
window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
- showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id])
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
+ session: sessionForecast(window))
} else {
QuotaWindowRow(
window: window,
@@ -97,7 +100,8 @@ struct ProviderCard: View {
chips: chips(for: window),
prominent: index == 0 && !showsLastKnown,
historical: showsLastKnown,
- showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id]
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
+ session: sessionForecast(window)
)
}
}
@@ -109,7 +113,8 @@ struct ProviderCard: View {
ForEach(visibleWindows) { window in
QuotaRingWindow(
window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
- showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id])
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
+ session: sessionForecast(window))
}
}
}
@@ -120,7 +125,8 @@ struct ProviderCard: View {
if index > 0 { Divider() }
QuotaRingRow(
window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
- showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id])
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
+ session: sessionForecast(window))
}
}
}
@@ -129,6 +135,17 @@ struct ProviderCard: View {
showHourlyPace && (state.status == .ready || showsLastKnown) && window.durationMinutes == 10080
}
+ private func sessionForecast(_ window: QuotaWindow) -> QuotaForecast? {
+ guard showsSessionForecast, state.status == .ready, window.durationMinutes == 300,
+ let forecast = QuotaForecast.make(provider: provider, window: window, rows: [], now: Date()),
+ forecast.pointsPerHour != nil
+ else { return nil }
+ switch forecast.verdict {
+ case .lasts, .tight, .runsOut: return forecast
+ case .noUsage, .exhausted: return nil
+ }
+ }
+
private var compactHeader: some View {
HStack(spacing: 7) {
ProviderLogo(provider: provider, size: 13)
diff --git a/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift b/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
index 831cb48..6ddd5cf 100644
--- a/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
@@ -41,6 +41,49 @@ struct QuotaPaceLabel: View {
}
}
+struct SessionForecastLabel: View {
+ let forecast: QuotaForecast
+
+ private var pace: String { UsageFormatters.percentage(forecast.pointsPerHour ?? 0) }
+
+ private var style: (symbol: String, tint: Color, text: String, detail: String) {
+ switch forecast.verdict {
+ case .runsOut(let date):
+ let time = date.formatted(
+ .dateTime.hour().minute().locale(Locale(identifier: LocalizationManager.shared.language.rawValue)))
+ return (
+ "exclamationmark.triangle.fill", .red, "session.runs_out".localized(time),
+ "session.detail_runs_out".localized(
+ pace, UsageFormatters.duration(forecast.reset.timeIntervalSince(date)))
+ )
+ case .tight(let margin):
+ return (
+ "exclamationmark.circle.fill", .orange, "session.tight".localized(pace),
+ "session.detail".localized(pace, UsageFormatters.percentage(max(0, margin)))
+ )
+ case .lasts(let margin):
+ return (
+ "checkmark.circle.fill", .green, "session.lasts".localized(pace),
+ "session.detail".localized(pace, UsageFormatters.percentage(margin))
+ )
+ case .noUsage, .exhausted: return ("", .clear, "", "")
+ }
+ }
+
+ var body: some View {
+ let style = style
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ Image(systemName: style.symbol).font(.system(size: 9, weight: .semibold)).foregroundStyle(style.tint)
+ .accessibilityHidden(true)
+ Text(style.text).monospacedDigit().lineLimit(1)
+ }
+ .font(.system(size: 10)).foregroundStyle(.secondary)
+ .help(style.detail)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(style.detail)
+ }
+}
+
struct QuotaPaceDetailsView: View {
let display: QuotaPaceDisplay
private var locale: Locale { Locale(identifier: LocalizationManager.shared.language.rawValue) }
diff --git a/Sources/TokenGaugeApp/Views/QuotaRing.swift b/Sources/TokenGaugeApp/Views/QuotaRing.swift
index 927a258..aa43e7b 100644
--- a/Sources/TokenGaugeApp/Views/QuotaRing.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaRing.swift
@@ -47,6 +47,7 @@ struct QuotaRingWindow: View {
var showPace = false
var pace: QuotaPace?
var previousPace: QuotaPace?
+ var session: QuotaForecast?
var metrics: QuotaRingMetrics = .grid
var alignsTitleRows = true
@@ -71,6 +72,7 @@ struct QuotaRingWindow: View {
.truncationMode(.tail)
.multilineTextAlignment(.center)
if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
+ if let session { SessionForecastLabel(forecast: session) }
if let summary = UsageFormatters.modelChips(chips) {
Text(summary)
.font(.system(size: 10))
@@ -84,7 +86,7 @@ struct QuotaRingWindow: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .accessibilityElement(children: showPace ? .contain : .combine)
+ .accessibilityElement(children: showPace || session != nil ? .contain : .combine)
}
}
@@ -96,6 +98,7 @@ struct QuotaRingRow: View {
var showPace = false
var pace: QuotaPace?
var previousPace: QuotaPace?
+ var session: QuotaForecast?
var body: some View {
HStack(spacing: Theme.Layout.quotaRingSpacing) {
@@ -123,18 +126,11 @@ struct QuotaRingRow: View {
.lineLimit(1)
.truncationMode(.tail)
if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
- if let summary = UsageFormatters.modelChips(chips) {
- Text(summary)
- .font(.system(size: 10))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.tail)
- .help(summary)
- .monospacedDigit()
- }
+ if let session { SessionForecastLabel(forecast: session) }
+ ModelChipsLabel(chips: chips)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
- .accessibilityElement(children: showPace ? .contain : .combine)
+ .accessibilityElement(children: showPace || session != nil ? .contain : .combine)
}
}
diff --git a/Sources/TokenGaugeApp/Views/QuotaWindowRow.swift b/Sources/TokenGaugeApp/Views/QuotaWindowRow.swift
index 762d781..abf6bd4 100644
--- a/Sources/TokenGaugeApp/Views/QuotaWindowRow.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaWindowRow.swift
@@ -10,6 +10,7 @@ struct QuotaWindowRow: View {
var showPace = false
var pace: QuotaPace?
var previousPace: QuotaPace?
+ var session: QuotaForecast?
private var valueColor: Color {
Theme.severity(remaining: window.remainingPercentage) ?? .primary
@@ -50,17 +51,33 @@ struct QuotaWindowRow: View {
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
- if let summary = UsageFormatters.modelChips(chips) {
- Text(summary)
- .font(.system(size: 10))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .monospacedDigit()
- .layoutPriority(-1)
- }
+ ModelChipsLabel(chips: chips).layoutPriority(-1)
}
if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
+ if let session { SessionForecastLabel(forecast: session) }
}
}
}
+
+struct ModelChipsLabel: View {
+ let chips: [ModelUsageChip]
+
+ var body: some View {
+ if let summary = UsageFormatters.modelChips(chips) {
+ ViewThatFits(in: .horizontal) {
+ line(chips)
+ line(Array(chips.prefix(1)))
+ }
+ .help(summary)
+ }
+ }
+
+ private func line(_ chips: [ModelUsageChip]) -> some View {
+ Text(UsageFormatters.modelChips(chips) ?? "")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .monospacedDigit()
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/StatsBarsView.swift b/Sources/TokenGaugeApp/Views/StatsBarsView.swift
new file mode 100644
index 0000000..6f24aca
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/StatsBarsView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+import TokenGaugeCore
+
+struct StatsBarsView: View {
+ let days: [StatsDayTotal]
+ let typical: Int?
+ let providers: [UsageProvider]
+ let locale: Locale
+ let tint: Color
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.quotaAnimationsEnabled) private var animateChanges
+ @State private var grown = false
+ @State private var hovered: String?
+
+ private let chartHeight: CGFloat = 70
+
+ private var maximum: Int { max(1, days.compactMap(\.total).max() ?? 1, typical ?? 0) }
+ private var focus: StatsDayTotal? { days.first { $0.id == hovered } ?? days.last }
+
+ static func color(_ segment: StatsSegment, providers: [UsageProvider]) -> Color {
+ if segment.id == StatsSummary.otherLabel { return Color.secondary.opacity(0.35) }
+ let base = segment.provider == .claude ? Theme.claude : Theme.codex
+ if providers.count > 1 { return base }
+ switch segment.rank {
+ case 0: return base
+ case 1: return Color(nsColor: NSColor(base).blended(withFraction: 0.45, of: .white) ?? NSColor(base))
+ default: return base.opacity(0.4)
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ caption
+ ZStack(alignment: .bottomLeading) {
+ HStack(alignment: .bottom, spacing: 4) {
+ ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
+ bar(day, index: index)
+ }
+ }
+ .frame(height: chartHeight, alignment: .bottom)
+ if let typical, typical > 0 {
+ typicalLine(typical)
+ }
+ }
+ HStack(spacing: 4) {
+ ForEach(days) { day in
+ Text(day.date.formatted(.dateTime.weekday(.narrow).locale(locale)))
+ .font(.system(size: 8, weight: day.id == days.last?.id ? .semibold : .regular))
+ .foregroundStyle(day.id == focus?.id ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
+ .frame(maxWidth: .infinity)
+ }
+ }
+ }
+ .onAppear {
+ guard !grown else { return }
+ if animateChanges && !reduceMotion {
+ withAnimation(.spring(response: 0.6, dampingFraction: 0.82).delay(0.15)) { grown = true }
+ } else {
+ grown = true
+ }
+ }
+ }
+
+ private var caption: some View {
+ HStack(spacing: 6) {
+ if let focus {
+ Text(focus.date.formatted(.dateTime.weekday(.wide).day().month(.abbreviated).locale(locale)))
+ .font(.system(size: 9.5)).foregroundStyle(.secondary)
+ Spacer(minLength: 4)
+ Text(focus.total.map(UsageFormatters.tokens) ?? "history.unknown".localized)
+ .font(.system(size: 11, weight: .semibold)).monospacedDigit()
+ .contentTransition(.numericText())
+ }
+ }
+ .animation(.easeOut(duration: 0.15), value: focus?.id)
+ }
+
+ private func bar(_ day: StatsDayTotal, index: Int) -> some View {
+ let height = CGFloat(day.total ?? 0) / CGFloat(maximum) * chartHeight
+ let isFocus = day.id == focus?.id
+ return VStack(spacing: 0) {
+ Spacer(minLength: 0)
+ VStack(spacing: 1) {
+ ForEach(day.segments) { segment in
+ Rectangle()
+ .fill(Self.color(segment, providers: providers))
+ .frame(height: segmentHeight(segment, of: day, height: height))
+ }
+ }
+ .frame(height: max(day.total == nil ? 0 : 2, height), alignment: .bottom)
+ .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous))
+ .scaleEffect(y: grown || !(animateChanges && !reduceMotion) ? 1 : 0.02, anchor: .bottom)
+ .animation(
+ animateChanges && !reduceMotion
+ ? .spring(response: 0.55, dampingFraction: 0.8).delay(0.012 * Double(index)) : nil,
+ value: grown)
+ if day.total == nil {
+ Capsule().fill(Color.secondary.opacity(0.25)).frame(width: 5, height: 1)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: chartHeight)
+ .background(
+ RoundedRectangle(cornerRadius: 4).fill(tint.opacity(isFocus && hovered != nil ? 0.08 : 0))
+ )
+ .contentShape(Rectangle())
+ .onHover { inside in
+ if inside { hovered = day.id } else if hovered == day.id { hovered = nil }
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(
+ day.date.formatted(.dateTime.weekday(.wide).day().month().locale(locale)) + ": "
+ + (day.total.map(UsageFormatters.tokens) ?? "history.unknown".localized))
+ }
+
+ private func segmentHeight(_ segment: StatsSegment, of day: StatsDayTotal, height: CGFloat) -> CGFloat {
+ let total = CGFloat(max(1, day.total ?? 1))
+ let share = CGFloat(segment.tokens) / total
+ return max(0, height * share - 1)
+ }
+
+ private func typicalLine(_ typical: Int) -> some View {
+ let y = chartHeight - CGFloat(typical) / CGFloat(maximum) * chartHeight
+ return ZStack(alignment: .topLeading) {
+ Path { path in
+ path.move(to: CGPoint(x: 0, y: y))
+ path.addLine(to: CGPoint(x: 2_000, y: y))
+ }
+ .stroke(Color.primary.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
+ Text("stats.daily.typical".localized)
+ .font(.system(size: 7.5, weight: .medium)).foregroundStyle(.secondary)
+ .padding(.horizontal, 3)
+ .background(Theme.panelBackground.opacity(0.85))
+ .offset(y: y - 12)
+ }
+ .frame(height: chartHeight)
+ .clipped()
+ .allowsHitTesting(false)
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/StatsView.swift b/Sources/TokenGaugeApp/Views/StatsView.swift
new file mode 100644
index 0000000..c64c975
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/StatsView.swift
@@ -0,0 +1,580 @@
+import SwiftUI
+import TokenGaugeCore
+
+struct StatsView: View {
+ @ObservedObject var store: UsageStore
+ @ObservedObject var model: StatsModel
+ let providers: [UsageProvider]
+ var scrolls = true
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.quotaAnimationsEnabled) private var animateChanges
+ @State private var selectedForecast: String?
+ @State private var appeared = false
+
+ private var locale: Locale { Locale(identifier: LocalizationManager.shared.language.rawValue) }
+ private var animates: Bool { animateChanges && !reduceMotion }
+ private var shown: Bool { appeared || !animates }
+ private var accent: Color { providers == [.codex] ? Theme.codex : Theme.claude }
+
+ private var forecasts: [QuotaForecast] {
+ let states = providers.compactMap { provider -> (UsageProvider, [QuotaWindow])? in
+ let state = store.state(for: provider)
+ guard state.status == .ready, let snapshot = state.snapshot else { return nil }
+ return (
+ provider,
+ WindowVisibility.visible(
+ snapshot.windows, provider: provider, showLunaReserve: store.showLunaReserve,
+ hiddenClaudeWindows: store.hiddenClaudeWindows)
+ )
+ }
+ return model.forecasts(for: states, accountFingerprint: store.claudeAccountFingerprint)
+ .sorted { urgency($0) < urgency($1) }
+ }
+
+ private var summary: StatsSummary? {
+ model.summary(for: providers, codexSummary: store.codex.snapshot?.summary)
+ }
+
+ var body: some View {
+ Group {
+ if scrolls {
+ ScrollView { sections }.scrollIndicators(.never)
+ } else {
+ sections
+ }
+ }
+ .onAppear {
+ guard !appeared else { return }
+ if animates { withAnimation(.easeOut(duration: 0.35)) { appeared = true } } else { appeared = true }
+ }
+ }
+
+ private var sections: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ if model.loadFailed {
+ placeholder("stats.load_failed".localized, symbol: "exclamationmark.triangle")
+ } else if model.records == nil {
+ placeholder("stats.loading".localized, symbol: "chart.xyaxis.line")
+ } else {
+ let forecasts = forecasts
+ section(0) { forecastCard(forecasts) }
+ if let summary {
+ section(1) { todayCard(summary) }
+ section(2) { totalsGrid(summary) }
+ section(3) { dailyCard(summary) }
+ if summary.turns != nil || summary.cachedShare != nil {
+ section(4) { activityCard(summary) }
+ }
+ section(5) { rankings(summary) }
+ }
+ }
+ }
+ .padding(.bottom, 4)
+ }
+
+ private func section(_ index: Int, @ViewBuilder content: () -> Content) -> some View {
+ content()
+ .opacity(shown ? 1 : 0)
+ .offset(y: shown ? 0 : 10)
+ .animation(
+ animates ? .spring(response: 0.42, dampingFraction: 0.85).delay(0.05 * Double(index)) : nil,
+ value: appeared)
+ }
+
+ private func placeholder(_ text: String, symbol: String) -> some View {
+ VStack(spacing: 8) {
+ Image(systemName: symbol).font(.system(size: 20, weight: .light)).accessibilityHidden(true).foregroundStyle(
+ .secondary)
+ Text(text).font(.system(size: 11)).foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity).frame(height: 180)
+ }
+
+ private func card(
+ _ id: StatsCard, _ title: String, symbol: String, trailing: String? = nil,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ let collapsed = store.collapsedStatsCards.contains(id)
+ return VStack(alignment: .leading, spacing: 9) {
+ Button {
+ withAnimation(animates ? .spring(response: 0.34, dampingFraction: 0.88) : nil) {
+ store.toggleStatsCard(id)
+ }
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: symbol).font(.system(size: 10, weight: .semibold)).accessibilityHidden(true)
+ .foregroundStyle(accent)
+ Text(title).font(.system(size: 11, weight: .semibold))
+ Spacer(minLength: 4)
+ if let trailing {
+ Text(trailing).font(.system(size: 9, weight: .medium)).foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Image(systemName: "chevron.down")
+ .font(.system(size: 8.5, weight: .semibold))
+ .foregroundStyle(.tertiary)
+ .rotationEffect(.degrees(collapsed ? -90 : 0))
+ .accessibilityHidden(true)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help((collapsed ? "stats.card.expand" : "stats.card.collapse").localized)
+ .accessibilityValue((collapsed ? "stats.card.collapsed" : "stats.card.expanded").localized)
+ if !collapsed {
+ content().transition(.opacity)
+ }
+ }
+ .padding(Theme.Layout.cardPadding)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .clipped()
+ .providerCard()
+ }
+
+ private func urgency(_ forecast: QuotaForecast) -> Double {
+ switch forecast.verdict {
+ case .exhausted: return -1_000
+ case .runsOut(let date): return -100 + date.timeIntervalSince(forecast.now) / 86_400
+ case .tight(let margin), .lasts(let margin): return margin
+ case .noUsage: return 1_000
+ }
+ }
+
+ @ViewBuilder private func forecastCard(_ forecasts: [QuotaForecast]) -> some View {
+ let selected = forecasts.first { $0.id == selectedForecast } ?? forecasts.first
+ card(.forecast, "stats.forecast.title".localized, symbol: "gauge.with.needle") {
+ if let selected {
+ VStack(alignment: .leading, spacing: 10) {
+ headline(selected)
+ ForecastChartView(forecast: selected, tint: color(selected.provider), locale: locale)
+ .id(selected.id)
+ .transition(.opacity)
+ paceLine(selected)
+ if forecasts.count > 1 {
+ VStack(spacing: 4) {
+ ForEach(forecasts) { forecast in
+ forecastRow(forecast, selected: forecast.id == selected.id)
+ }
+ }
+ }
+ }
+ .animation(animates ? .easeInOut(duration: 0.22) : nil, value: selected.id)
+ } else {
+ Text("stats.forecast.unavailable".localized)
+ .font(.system(size: 11)).foregroundStyle(.secondary)
+ }
+ }
+ .help((selected?.isSession == true ? "stats.forecast.help_session" : "stats.forecast.help").localized)
+ }
+
+ private func headline(_ forecast: QuotaForecast) -> some View {
+ let (symbol, tint) = verdictStyle(forecast.verdict)
+ return HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Image(systemName: symbol).font(.system(size: 15, weight: .semibold)).accessibilityHidden(true)
+ .foregroundStyle(tint)
+ .symbolRenderingMode(.hierarchical)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(headlineText(forecast.verdict))
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(tint)
+ .contentTransition(.opacity)
+ Text(verdictText(forecast))
+ .font(.system(size: 10.5, weight: .medium)).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 4)
+ VStack(alignment: .trailing, spacing: 2) {
+ HStack(spacing: 4) {
+ ProviderLogo(provider: forecast.provider, size: 11)
+ Text(windowName(forecast)).font(.system(size: 10, weight: .semibold)).lineLimit(1)
+ }
+ Text(UsageFormatters.reset(forecast.reset))
+ .font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ }
+
+ private func paceLine(_ forecast: QuotaForecast) -> some View {
+ HStack(spacing: 10) {
+ let session = forecast.isSession
+ if let average = forecast.pointsPerHour {
+ label(
+ "speedometer",
+ (session ? "stats.pace.session" : "stats.pace.average").localized(pace(average, forecast)))
+ }
+ if let recent = forecast.recentPointsPerHour {
+ label(
+ "clock.arrow.circlepath",
+ (session ? "stats.pace.hour" : "stats.pace.today").localized(pace(recent, forecast)))
+ }
+ Spacer(minLength: 0)
+ if forecast.remaining > 0, forecast.pointsPerHour != nil {
+ label(
+ session ? "hourglass" : "calendar",
+ (session ? "stats.budget.hour" : "stats.budget").localized(
+ UsageFormatters.percentage(forecast.budget)))
+ }
+ }
+ .font(.system(size: 9.5))
+ .foregroundStyle(.secondary)
+ }
+
+ private func label(_ symbol: String, _ text: String) -> some View {
+ HStack(spacing: 3) {
+ Image(systemName: symbol).font(.system(size: 8.5)).accessibilityHidden(true)
+ Text(text).lineLimit(1).monospacedDigit()
+ }
+ }
+
+ private func forecastRow(_ forecast: QuotaForecast, selected: Bool) -> some View {
+ let (symbol, tint) = verdictStyle(forecast.verdict)
+ return Button {
+ selectedForecast = forecast.id
+ } label: {
+ HStack(spacing: 7) {
+ ProviderLogo(provider: forecast.provider, size: 11)
+ Text(windowName(forecast)).font(.system(size: 10.5, weight: .medium)).lineLimit(1)
+ Spacer(minLength: 6)
+ Image(systemName: symbol).font(.system(size: 9, weight: .semibold)).accessibilityHidden(true)
+ .foregroundStyle(tint)
+ Text(shortVerdict(forecast)).font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1)
+ .monospacedDigit()
+ }
+ .padding(.horizontal, 8).frame(height: 24)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(selected ? color(forecast.provider).opacity(0.12) : Color.primary.opacity(0.03))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(selected ? color(forecast.provider).opacity(0.35) : .clear, lineWidth: 1)
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PressableStyle())
+ .accessibilityAddTraits(selected ? [.isSelected] : [])
+ }
+
+ private func verdictStyle(_ verdict: QuotaForecast.Verdict) -> (String, Color) {
+ switch verdict {
+ case .lasts: return ("checkmark.seal.fill", .green)
+ case .tight: return ("exclamationmark.circle.fill", .orange)
+ case .runsOut: return ("exclamationmark.triangle.fill", .red)
+ case .exhausted: return ("xmark.octagon.fill", .red)
+ case .noUsage: return ("moon.zzz.fill", .secondary)
+ }
+ }
+
+ private func headlineText(_ verdict: QuotaForecast.Verdict) -> String {
+ switch verdict {
+ case .lasts: return "stats.headline.lasts".localized
+ case .tight: return "stats.headline.tight".localized
+ case .runsOut: return "stats.headline.runs_out".localized
+ case .exhausted: return "stats.headline.exhausted".localized
+ case .noUsage: return "stats.headline.no_usage".localized
+ }
+ }
+
+ private func verdictText(_ forecast: QuotaForecast) -> String {
+ switch forecast.verdict {
+ case .lasts(let margin): return "stats.verdict.lasts".localized(UsageFormatters.percentage(margin))
+ case .tight(let margin): return "stats.verdict.tight".localized(UsageFormatters.percentage(max(0, margin)))
+ case .runsOut(let date):
+ return (forecast.isSession ? "stats.verdict.runs_out_session" : "stats.verdict.runs_out").localized(
+ moment(date, forecast),
+ UsageFormatters.duration(forecast.reset.timeIntervalSince(date)))
+ case .exhausted: return "stats.verdict.exhausted".localized
+ case .noUsage: return "stats.verdict.no_usage".localized
+ }
+ }
+
+ private func shortVerdict(_ forecast: QuotaForecast) -> String {
+ switch forecast.verdict {
+ case .lasts(let margin), .tight(let margin):
+ return "stats.row.left".localized(UsageFormatters.percentage(max(0, margin)))
+ case .runsOut(let date): return moment(date, forecast)
+ case .exhausted: return "stats.headline.exhausted".localized
+ case .noUsage: return UsageFormatters.percentage(forecast.remaining)
+ }
+ }
+
+ private func windowName(_ forecast: QuotaForecast) -> String {
+ UsageFormatters.windowName(
+ QuotaWindow(
+ id: forecast.windowID, usedPercentage: 100 - forecast.remaining, resetsAt: forecast.reset,
+ durationMinutes: Int(forecast.reset.timeIntervalSince(forecast.start) / 60),
+ displayName: forecast.displayName))
+ }
+
+ private func pace(_ value: Double, _ forecast: QuotaForecast) -> String {
+ UsageFormatters.percentage(value * forecast.budgetUnit / 3600)
+ }
+
+ private func moment(_ date: Date, _ forecast: QuotaForecast) -> String {
+ date.formatted(
+ forecast.isSession
+ ? .dateTime.hour().minute().locale(locale)
+ : .dateTime.weekday(.abbreviated).hour().minute().locale(locale))
+ }
+
+ private func todayCard(_ summary: StatsSummary) -> some View {
+ card(
+ .today, "stats.today.title".localized, symbol: "sun.max.fill",
+ trailing: summary.typicalDay.map { "stats.today.typical".localized(UsageFormatters.tokens($0)) }
+ ) {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(summary.today.map(UsageFormatters.tokens) ?? "—")
+ .font(.system(size: 24, weight: .bold, design: .rounded)).monospacedDigit()
+ .contentTransition(.numericText())
+ if let ratio = summary.ratio {
+ Text("stats.today.ratio".localized(ratioText(ratio)))
+ .font(.system(size: 10, weight: .semibold)).monospacedDigit()
+ .foregroundStyle(ratioColor(ratio))
+ .padding(.horizontal, 7).padding(.vertical, 3)
+ .background(Capsule().fill(ratioColor(ratio).opacity(0.13)))
+ }
+ Spacer(minLength: 0)
+ }
+ if let insight = summary.insight {
+ HStack(alignment: .top, spacing: 6) {
+ Image(systemName: insightSymbol(insight)).font(.system(size: 10, weight: .semibold))
+ .accessibilityHidden(true)
+ .foregroundStyle(insightColor(insight))
+ Text(insightText(insight)).font(.system(size: 10.5)).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ }
+ }
+
+ private func ratioText(_ ratio: Double) -> String {
+ ratio.formatted(.number.precision(.fractionLength(ratio >= 10 ? 0 : 1)).locale(locale))
+ }
+
+ private func ratioColor(_ ratio: Double) -> Color {
+ if ratio >= 1.8 { return .orange }
+ if ratio <= 0.4 { return Theme.codex }
+ return .secondary
+ }
+
+ private func insightSymbol(_ insight: StatsInsight) -> String {
+ switch insight {
+ case .newModel: return "sparkles"
+ case .spike: return "flame.fill"
+ case .quiet: return "moon.fill"
+ case .steady: return "checkmark.circle.fill"
+ }
+ }
+
+ private func insightColor(_ insight: StatsInsight) -> Color {
+ switch insight {
+ case .newModel: return .purple
+ case .spike: return .orange
+ case .quiet: return Theme.codex
+ case .steady: return .green
+ }
+ }
+
+ private func insightText(_ insight: StatsInsight) -> String {
+ switch insight {
+ case .newModel(let model): return "stats.insight.new_model".localized(model)
+ case .spike: return "stats.insight.spike".localized
+ case .quiet: return "stats.insight.quiet".localized
+ case .steady: return "stats.insight.steady".localized
+ }
+ }
+
+ private func totalsGrid(_ summary: StatsSummary) -> some View {
+ let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
+ return LazyVGrid(columns: columns, spacing: 8) {
+ tile(
+ "stats.week".localized, value: UsageFormatters.tokens(summary.week),
+ delta: delta(summary.week, summary.previousWeek))
+ tile(
+ "stats.month".localized, value: UsageFormatters.tokens(summary.month),
+ delta: delta(summary.month, summary.previousMonth))
+ tile(
+ "stats.lifetime".localized, value: UsageFormatters.tokens(summary.lifetime),
+ caption: summary.since.map {
+ "stats.since".localized($0.formatted(.dateTime.month(.abbreviated).year().locale(locale)))
+ })
+ tile(
+ "stats.peak".localized, value: summary.peak.map { UsageFormatters.tokens($0.tokens) } ?? "—",
+ caption: summary.peak.map { $0.date.formatted(.dateTime.day().month(.abbreviated).locale(locale)) })
+ tile(
+ "stats.streak".localized, value: "stats.days_value".localized(String(summary.streak.current)),
+ caption: "stats.streak_best".localized(String(summary.streak.longest)), symbol: "flame.fill")
+ tile(
+ "stats.active_days".localized, value: String(summary.activeDays),
+ caption: summary.since.map {
+ "stats.since".localized($0.formatted(.dateTime.day().month(.abbreviated).locale(locale)))
+ })
+ }
+ }
+
+ private func delta(_ current: Int, _ previous: Int?) -> Double? {
+ guard let previous, previous > 0 else { return nil }
+ return Double(current - previous) / Double(previous)
+ }
+
+ private func tile(
+ _ title: String, value: String, delta: Double? = nil, caption: String? = nil, symbol: String? = nil
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 3) {
+ if let symbol {
+ Image(systemName: symbol).font(.system(size: 8, weight: .semibold)).accessibilityHidden(true)
+ .foregroundStyle(accent)
+ }
+ Text(title).font(.system(size: 9, weight: .medium)).foregroundStyle(.secondary).lineLimit(1)
+ }
+ Text(value).font(.system(size: 15, weight: .bold, design: .rounded)).monospacedDigit()
+ .lineLimit(1).minimumScaleFactor(0.7)
+ .contentTransition(.numericText())
+ if let delta {
+ Text((delta >= 0 ? "▲ " : "▼ ") + UsageFormatters.percentage(abs(delta) * 100))
+ .font(.system(size: 8.5, weight: .semibold)).monospacedDigit()
+ .foregroundStyle(delta >= 0 ? Color.orange : Color.green)
+ .help("stats.vs_previous".localized)
+ } else if let caption {
+ Text(caption).font(.system(size: 8.5)).foregroundStyle(.tertiary).lineLimit(1)
+ } else {
+ Text(" ").font(.system(size: 8.5))
+ }
+ }
+ .padding(.horizontal, 9).padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.04)))
+ }
+
+ private func dailyCard(_ summary: StatsSummary) -> some View {
+ card(.daily, "stats.daily.title".localized, symbol: "chart.bar.fill") {
+ VStack(alignment: .leading, spacing: 8) {
+ StatsBarsView(
+ days: summary.recent, typical: summary.typicalDay, providers: providers, locale: locale,
+ tint: accent)
+ legend(summary.legend)
+ }
+ }
+ }
+
+ private func legend(_ items: [StatsSegment]) -> some View {
+ HStack(spacing: 10) {
+ ForEach(items) { item in
+ HStack(spacing: 4) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(StatsBarsView.color(item, providers: providers))
+ .frame(width: 8, height: 8)
+ Text(segmentName(item.id)).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func segmentName(_ id: String) -> String {
+ if id == StatsSummary.otherLabel { return "stats.other".localized }
+ if let provider = UsageProvider(rawValue: id) { return name(provider) }
+ return id
+ }
+
+ private func activityCard(_ summary: StatsSummary) -> some View {
+ card(.activity, "stats.activity.title".localized, symbol: "bolt.fill") {
+ HStack(spacing: 8) {
+ if let turns = summary.turns, turns.todayCount > 0 {
+ tile(
+ "stats.activity.today".localized,
+ value: UsageFormatters.duration(Double(turns.todayMilliseconds) / 1000),
+ caption: "stats.activity.turns".localized(String(turns.todayCount)))
+ }
+ if let turns = summary.turns {
+ tile(
+ "stats.activity.longest".localized,
+ value: UsageFormatters.duration(Double(turns.longestMilliseconds) / 1000),
+ caption: "stats.activity.longest_hint".localized)
+ }
+ if let share = summary.cachedShare {
+ tile(
+ "stats.activity.cache".localized, value: UsageFormatters.percentage(share * 100),
+ caption: "stats.activity.cache_hint".localized)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private func rankings(_ summary: StatsSummary) -> some View {
+ VStack(spacing: 10) {
+ if !summary.models.isEmpty {
+ card(.models, "stats.models.title".localized, symbol: "cpu") { rankList(summary.models, unit: nil) }
+ }
+ if !summary.efforts.isEmpty {
+ card(.efforts, "stats.efforts.title".localized, symbol: "brain.head.profile") {
+ rankList(summary.efforts, unit: nil)
+ }
+ }
+ if !summary.skills.isEmpty {
+ card(.skills, "stats.skills.title".localized, symbol: "wand.and.stars") {
+ rankList(summary.skills, unit: "stats.skills.runs")
+ }
+ }
+ }
+ }
+
+ private func rankList(_ items: [StatsRankItem], unit: String?) -> some View {
+ let maximum = max(1, items.map(\.value).max() ?? 1)
+ let total = max(1, items.map(\.value).reduce(0, +))
+ return VStack(spacing: 6) {
+ ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
+ let tint = item.provider.map(color) ?? accent
+ HStack(spacing: 8) {
+ if let provider = item.provider, providers.count > 1 {
+ ProviderLogo(provider: provider, size: 10)
+ }
+ Text(item.label).font(.system(size: 10.5, weight: .medium)).lineLimit(1).truncationMode(.middle)
+ .frame(width: unit == nil ? 104 : 150, alignment: .leading)
+ .help(item.label)
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.06))
+ Capsule().fill(tint.opacity(1 - Double(index) * 0.12))
+ .frame(
+ width: shown
+ ? max(4, proxy.size.width * CGFloat(item.value) / CGFloat(maximum)) : 4
+ )
+ .animation(
+ animates
+ ? .spring(response: 0.55, dampingFraction: 0.8).delay(
+ 0.25 + 0.04 * Double(index))
+ : nil, value: appeared)
+ }
+ }
+ .frame(height: 6)
+ Text(
+ unit.map { (item.value == 1 ? $0 + "_one" : $0).localized(String(item.value)) }
+ ?? UsageFormatters.tokens(item.value)
+ )
+ .font(.system(size: 9.5, weight: .semibold)).monospacedDigit()
+ .foregroundStyle(.secondary)
+ .frame(width: unit == nil ? 44 : 58, alignment: .trailing)
+ .help(unit == nil ? UsageFormatters.percentage(Double(item.value) * 100 / Double(total)) : "")
+ }
+ }
+ }
+ }
+
+ private func color(_ provider: UsageProvider) -> Color { provider == .claude ? Theme.claude : Theme.codex }
+ private func name(_ provider: UsageProvider) -> String {
+ (provider == .claude ? "provider.claude_short" : "provider.codex").localized
+ }
+
+}
+
+struct PressableStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .scaleEffect(configuration.isPressed ? 0.97 : 1)
+ .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
+ }
+}
diff --git a/Sources/TokenGaugeCapture/main.swift b/Sources/TokenGaugeCapture/main.swift
index da86b14..72c3e7d 100644
--- a/Sources/TokenGaugeCapture/main.swift
+++ b/Sources/TokenGaugeCapture/main.swift
@@ -7,16 +7,18 @@ enum TokenGaugeCapture {
let arguments = Set(CommandLine.arguments.dropFirst())
if arguments.contains("--collect") {
let scan = try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL())
- let recorded = (try? UsageHistoryStore.recordEffort(scan.effortRecords)) != nil
+ let recorded =
+ (try? UsageHistoryStore.recordEffort(scan.effortRecords, activity: scan.activityRecords)) != nil
try emit(CaptureCollection(buckets: scan.buckets, effortRecords: recorded ? scan.effortRecords.count : nil))
return
}
let recordEffort = arguments.contains("--record-effort-history")
if recordEffort || arguments.contains("--effort-history") {
do {
- let records = try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL()).effortRecords
+ let scan = try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL())
+ let records = scan.effortRecords
if recordEffort {
- try UsageHistoryStore.recordEffort(records)
+ try UsageHistoryStore.recordEffort(records, activity: scan.activityRecords)
try emit(["records": records.count])
} else {
try emit(records)
diff --git a/Sources/TokenGaugeCore/ActivityUsageRecord.swift b/Sources/TokenGaugeCore/ActivityUsageRecord.swift
new file mode 100644
index 0000000..8978c2b
--- /dev/null
+++ b/Sources/TokenGaugeCore/ActivityUsageRecord.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+public enum HistoryActivityKind: String, Codable, Sendable {
+ case skill, turn
+}
+
+public struct ActivityUsageRecord: Equatable, Sendable {
+ public let id: String
+ public let provider: UsageProvider
+ public let recordedAt: Date
+ public let day: String
+ public let kind: HistoryActivityKind
+ public let key: String
+ public let value: Int
+
+ public init(
+ id: String, provider: UsageProvider, recordedAt: Date, day: String, kind: HistoryActivityKind, key: String,
+ value: Int
+ ) {
+ self.id = id
+ self.provider = provider
+ self.recordedAt = recordedAt
+ self.day = day
+ self.kind = kind
+ self.key = key
+ self.value = value
+ }
+
+ private static let skillCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789._:-")
+ private static let skillLeadingCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789")
+
+ static func isSkillName(_ name: String) -> Bool {
+ guard let first = name.unicodeScalars.first, name.utf8.count <= 64,
+ skillLeadingCharacters.contains(first)
+ else { return false }
+ return name.unicodeScalars.allSatisfy(skillCharacters.contains)
+ }
+
+ var isValid: Bool {
+ guard value >= 0 else { return false }
+ switch kind {
+ case .skill: guard Self.isSkillName(key) else { return false }
+ case .turn: guard key.isEmpty else { return false }
+ }
+ return EffortUsageRecord.isValid(id: id, recordedAt: recordedAt, day: day)
+ }
+}
+
+public struct HistoryActivityRow: Equatable, Sendable {
+ public let day: String
+ public let provider: UsageProvider
+ public let kind: HistoryActivityKind
+ public let key: String
+ public let count: Int
+ public let total: Int
+ public let maximum: Int
+
+ public init(
+ day: String, provider: UsageProvider, kind: HistoryActivityKind, key: String, count: Int, total: Int,
+ maximum: Int
+ ) {
+ self.day = day
+ self.provider = provider
+ self.kind = kind
+ self.key = key
+ self.count = count
+ self.total = total
+ self.maximum = maximum
+ }
+}
+
+public struct HistoryCachedRow: Equatable, Sendable {
+ public let day: String
+ public let provider: UsageProvider
+ public let cachedTokens: Int
+ public let tokens: Int
+
+ public init(day: String, provider: UsageProvider, cachedTokens: Int, tokens: Int) {
+ self.day = day
+ self.provider = provider
+ self.cachedTokens = cachedTokens
+ self.tokens = tokens
+ }
+}
diff --git a/Sources/TokenGaugeCore/ActivityUsageScanner.swift b/Sources/TokenGaugeCore/ActivityUsageScanner.swift
new file mode 100644
index 0000000..0872c25
--- /dev/null
+++ b/Sources/TokenGaugeCore/ActivityUsageScanner.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+enum ActivityUsageScanner {
+ private static let readCommands: Set = ["sed", "cat", "head", "nl", "less", "bat", "tail"]
+ private static let commandBoundaries = CharacterSet(charactersIn: "\"\n;|&(`{")
+ private static let skillPath = try! NSRegularExpression(
+ pattern: #"(? [ActivityEntry] {
+ let time = date.timeIntervalSince1970
+ if metadata.type == "system", metadata.subtype == "turn_duration" {
+ guard !subagent, metadata.isSidechain != true, let uuid = metadata.uuid, !uuid.isEmpty,
+ let duration = metadata.durationMs, duration >= 0
+ else { return [] }
+ return [ActivityEntry(id: hash(.claude, "turn:\(uuid)"), at: time, kind: .turn, key: "", value: duration)]
+ }
+ guard metadata.type == "assistant", let message = parser.skillCalls(line)?.message,
+ let messageID = message.id, !messageID.isEmpty
+ else { return [] }
+ return (message.content ?? []).compactMap { item in
+ guard item.type == "tool_use", item.name == "Skill", let id = item.id, !id.isEmpty,
+ let skill = item.skill, ActivityUsageRecord.isSkillName(skill)
+ else { return nil }
+ return ActivityEntry(
+ id: hash(.claude, "skill:\(messageID):\(id)"), at: time, kind: .skill, key: skill, value: 1)
+ }
+ }
+
+ static func codex(
+ _ metadata: EffortTranscriptMetadata, line: Data, at date: Date, rollout: String, subagent: Bool,
+ parser: TranscriptLineParser
+ ) -> [ActivityEntry] {
+ let time = date.timeIntervalSince1970
+ guard let payload = metadata.payload else { return [] }
+ switch (metadata.type, payload.type) {
+ case ("event_msg", "task_complete"):
+ guard !subagent, let turn = payload.turn_id, !turn.isEmpty, let duration = payload.duration_ms,
+ duration >= 0
+ else { return [] }
+ return [ActivityEntry(id: hash(.codex, "turn:\(turn)"), at: time, kind: .turn, key: "", value: duration)]
+ case ("response_item", "function_call"), ("response_item", "custom_tool_call"):
+ guard let call = parser.toolCall(line)?.payload else { return [] }
+ return readSkills(in: call.arguments ?? call.input ?? "").sorted().map { skill in
+ ActivityEntry(
+ id: hash(.codex, "skill:\(rollout):\(skill)"), at: time, kind: .skill, key: skill, value: 1)
+ }
+ default:
+ return []
+ }
+ }
+
+ static func readSkills(in command: String) -> Set {
+ guard command.contains("SKILL.md") else { return [] }
+ let text = command.replacingOccurrences(of: "\\n", with: "\n") as NSString
+ var skills: Set = []
+ for match in skillPath.matches(in: text as String, range: NSRange(location: 0, length: text.length)) {
+ let prefix = text.substring(to: match.range.location)
+ let start = prefix.rangeOfCharacter(from: commandBoundaries, options: .backwards)?.upperBound
+ let segment = start.map { prefix[$0...] } ?? prefix[...]
+ guard let program = segment.split(whereSeparator: \.isWhitespace).first?.split(separator: "/").last,
+ readCommands.contains(String(program))
+ else { continue }
+ skills.insert(text.substring(with: match.range(at: 1)))
+ }
+ return skills
+ }
+
+ static func records(
+ _ entries: some Sequence<(provider: UsageProvider, entry: ActivityEntry)>, cutoff: Date, now: Date,
+ calendar: Calendar
+ ) -> [ActivityUsageRecord] {
+ let earliest = cutoff.timeIntervalSince1970
+ let latest = now.timeIntervalSince1970
+ return entries.compactMap { provider, entry -> ActivityUsageRecord? in
+ guard entry.at >= earliest, entry.at <= latest else { return nil }
+ let recordedAt = Date(timeIntervalSince1970: entry.at)
+ return ActivityUsageRecord(
+ id: entry.id, provider: provider, recordedAt: recordedAt,
+ day: TranscriptScanner.dayString(recordedAt, calendar: calendar), kind: entry.kind, key: entry.key,
+ value: entry.value)
+ }.sorted { $0.recordedAt == $1.recordedAt ? $0.id < $1.id : $0.recordedAt < $1.recordedAt }
+ }
+
+ private static func hash(_ provider: UsageProvider, _ identifier: String) -> String {
+ EffortUsageScanner.hash(provider: provider, identifier: identifier)
+ }
+}
diff --git a/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift b/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
index 98c1660..ffe6542 100644
--- a/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
+++ b/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
@@ -23,7 +23,7 @@ public enum ClaudeHistoryScanner {
tokens: tokens, model: normalizedModel(message.model),
effortModel: EffortTranscriptMetadata.normalizedModel(message.model),
effort: EffortTranscriptMetadata.normalizedEffort(metadata.perTurnEffort ?? metadata.effort),
- eligible: message.id?.isEmpty == false)
+ eligible: message.id?.isEmpty == false, cachedTokens: usage.cache_read_input_tokens.map { max(0, $0) })
}
static func buckets(
diff --git a/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift b/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
index 4ae3e75..1311a7f 100644
--- a/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
+++ b/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
@@ -4,13 +4,19 @@ struct EffortTranscriptMetadata: Decodable {
let type: String?
let timestamp: String?
let uuid: String?
+ let subtype: String?
+ let durationMs: Int?
+ let isSidechain: Bool?
let perTurnEffort: String?
let effort: String?
let payload: Payload?
let message: Message?
struct Payload: Decodable {
+ let type: String?
let turn_id: String?
+ let duration_ms: Int?
+ let source: Source?
let response_id: String?
let model: String?
let effort: String?
@@ -29,6 +35,7 @@ struct EffortTranscriptMetadata: Decodable {
let output_tokens: Int?
let cache_creation_input_tokens: Int?
let cache_read_input_tokens: Int?
+ let cached_input_tokens: Int?
var claudeTotal: Int? {
var total = 0
@@ -41,6 +48,57 @@ struct EffortTranscriptMetadata: Decodable {
}
}
+ struct Source: Decodable {
+ let isSubagent: Bool
+
+ private enum CodingKeys: String, CodingKey { case subagent }
+
+ init(from decoder: Decoder) throws {
+ if let value = try? decoder.singleValueContainer().decode(String.self) {
+ isSubagent = value == "subagent"
+ } else {
+ isSubagent = (try? decoder.container(keyedBy: CodingKeys.self).contains(.subagent)) == true
+ }
+ }
+ }
+
+ struct SkillCalls: Decodable {
+ let message: Message?
+
+ struct Message: Decodable {
+ let id: String?
+ let content: [Item]?
+ }
+
+ struct Item: Decodable {
+ let type: String?
+ let id: String?
+ let name: String?
+ let skill: String?
+
+ private enum CodingKeys: String, CodingKey { case type, id, name, input }
+ private enum InputKeys: String, CodingKey { case skill }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ type = try? container.decode(String.self, forKey: .type)
+ id = try? container.decode(String.self, forKey: .id)
+ name = try? container.decode(String.self, forKey: .name)
+ skill = try? container.nestedContainer(keyedBy: InputKeys.self, forKey: .input).decode(
+ String.self, forKey: .skill)
+ }
+ }
+ }
+
+ struct ToolCall: Decodable {
+ let payload: Payload?
+
+ struct Payload: Decodable {
+ let arguments: String?
+ let input: String?
+ }
+ }
+
private static let efforts: Set = [
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "auto",
]
diff --git a/Sources/TokenGaugeCore/EffortUsageRecord.swift b/Sources/TokenGaugeCore/EffortUsageRecord.swift
index a685f98..9a24147 100644
--- a/Sources/TokenGaugeCore/EffortUsageRecord.swift
+++ b/Sources/TokenGaugeCore/EffortUsageRecord.swift
@@ -8,9 +8,11 @@ public struct EffortUsageRecord: Codable, Equatable, Sendable {
public let model: String
public let effort: String
public let tokens: Int
+ public let cachedTokens: Int?
public init(
- id: String, provider: UsageProvider, recordedAt: Date, day: String, model: String, effort: String, tokens: Int
+ id: String, provider: UsageProvider, recordedAt: Date, day: String, model: String, effort: String, tokens: Int,
+ cachedTokens: Int? = nil
) {
self.id = id
self.provider = provider
@@ -19,6 +21,7 @@ public struct EffortUsageRecord: Codable, Equatable, Sendable {
self.model = model
self.effort = effort
self.tokens = tokens
+ self.cachedTokens = cachedTokens
}
private static let hexCharacters = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
@@ -34,19 +37,24 @@ public struct EffortUsageRecord: Codable, Equatable, Sendable {
}()
var isValid: Bool {
- guard id.utf8.count == 64, id.unicodeScalars.allSatisfy(Self.hexCharacters.contains),
- !model.isEmpty, model.utf8.count <= 128,
+ guard !model.isEmpty, model.utf8.count <= 128,
model.unicodeScalars.allSatisfy(Self.identifierCharacters.contains),
- Self.efforts.contains(effort), tokens >= 0,
+ Self.efforts.contains(effort), tokens >= 0, (cachedTokens ?? 0) >= 0
+ else { return false }
+ return Self.isValid(id: id, recordedAt: recordedAt, day: day)
+ }
+
+ static func isValid(id: String, recordedAt: Date, day: String) -> Bool {
+ guard id.utf8.count == 64, id.unicodeScalars.allSatisfy(hexCharacters.contains),
recordedAt.timeIntervalSince1970.isFinite,
(0...253_402_300_799).contains(recordedAt.timeIntervalSince1970),
- Self.isDayFormat(day)
+ isDayFormat(day)
else { return false }
let parts = day.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3, (1970...9999).contains(parts[0]) else { return false }
let components = DateComponents(year: parts[0], month: parts[1], day: parts[2])
- guard let date = Self.utcCalendar.date(from: components) else { return false }
- return Self.utcCalendar.dateComponents([.year, .month, .day], from: date) == components
+ guard let date = utcCalendar.date(from: components) else { return false }
+ return utcCalendar.dateComponents([.year, .month, .day], from: date) == components
}
private static func isDayFormat(_ day: String) -> Bool {
diff --git a/Sources/TokenGaugeCore/EffortUsageScanner.swift b/Sources/TokenGaugeCore/EffortUsageScanner.swift
index dcb6194..672c334 100644
--- a/Sources/TokenGaugeCore/EffortUsageScanner.swift
+++ b/Sources/TokenGaugeCore/EffortUsageScanner.swift
@@ -24,7 +24,8 @@ public enum EffortUsageScanner {
let time = date.timeIntervalSince1970
return ScanEntry(
id: hash(provider: .codex, identifier: identifier), firstAt: time, peakAt: time, tokens: tokens,
- model: model, effortModel: model, effort: matching?.effort ?? "unknown", eligible: true)
+ model: model, effortModel: model, effort: matching?.effort ?? "unknown", eligible: true,
+ cachedTokens: payload.usage?.cached_input_tokens.map { max(0, $0) })
}
static func records(
@@ -39,7 +40,7 @@ public enum EffortUsageScanner {
return EffortUsageRecord(
id: entry.id, provider: provider, recordedAt: recordedAt,
day: TranscriptScanner.dayString(recordedAt, calendar: calendar),
- model: entry.effortModel, effort: entry.effort, tokens: entry.tokens)
+ model: entry.effortModel, effort: entry.effort, tokens: entry.tokens, cachedTokens: entry.cachedTokens)
}.sorted { $0.recordedAt == $1.recordedAt ? $0.id < $1.id : $0.recordedAt < $1.recordedAt }
}
diff --git a/Sources/TokenGaugeCore/QuotaForecast.swift b/Sources/TokenGaugeCore/QuotaForecast.swift
new file mode 100644
index 0000000..666a425
--- /dev/null
+++ b/Sources/TokenGaugeCore/QuotaForecast.swift
@@ -0,0 +1,201 @@
+import Foundation
+
+public struct ForecastPoint: Equatable, Sendable {
+ public let date: Date
+ public let remaining: Double
+
+ public init(date: Date, remaining: Double) {
+ self.date = date
+ self.remaining = remaining
+ }
+}
+
+public struct QuotaForecast: Equatable, Identifiable, Sendable {
+ public enum Verdict: Equatable, Sendable {
+ case noUsage
+ case lasts(margin: Double)
+ case tight(margin: Double)
+ case runsOut(at: Date)
+ case exhausted
+ }
+
+ public let provider: UsageProvider
+ public let windowID: String
+ public let displayName: String?
+ public let start: Date
+ public let reset: Date
+ public let now: Date
+ public let points: [ForecastPoint]
+ public let remaining: Double
+ public let boosts: [ForecastPoint]
+ public let away: [DateInterval]
+ public let pointsPerHour: Double?
+ public let recentPointsPerHour: Double?
+
+ public struct Horizon: Equatable, Sendable {
+ public let pace: TimeInterval
+ public let recent: TimeInterval
+ let minimumPace: TimeInterval
+ let minimumRecent: TimeInterval
+ let spansPreviousWindows: Bool
+
+ public static let weekly = Horizon(
+ pace: 72 * 3600, recent: 24 * 3600, minimumPace: 3 * 3600, minimumRecent: 6 * 3600,
+ spansPreviousWindows: true)
+ public static let session = Horizon(
+ pace: 5 * 3600, recent: 3600, minimumPace: 10 * 60, minimumRecent: 30 * 60, spansPreviousWindows: false)
+
+ public static func of(durationMinutes minutes: Int) -> Horizon { minutes < 1_440 ? .session : .weekly }
+ }
+
+ public static let tightMargin = 10.0
+ static let boostThreshold = 3.0
+ static let resetTolerance: TimeInterval = 3600
+
+ public var id: String { provider.rawValue + ":" + windowID }
+ public var isSession: Bool { reset.timeIntervalSince(start) < 86_400 }
+ public var horizon: Horizon { isSession ? .session : .weekly }
+ public var budgetUnit: TimeInterval { isSession ? 3600 : 86_400 }
+ public var hoursLeft: Double { max(0, reset.timeIntervalSince(now) / 3600) }
+
+ public var projectedRemainingAtReset: Double? {
+ pointsPerHour.map { remaining - $0 * hoursLeft }
+ }
+
+ public var runsOutAt: Date? { exhaustion(pace: pointsPerHour) }
+ public var recentRunsOutAt: Date? { exhaustion(pace: recentPointsPerHour) }
+
+ public var budget: Double { remaining / max(hoursLeft * 3600 / budgetUnit, 1) }
+
+ public var verdict: Verdict {
+ if remaining <= 0 { return .exhausted }
+ guard let projected = projectedRemainingAtReset else {
+ return remaining >= 100 ? .noUsage : margin(remaining)
+ }
+ if projected <= 0, let date = runsOutAt { return .runsOut(at: date) }
+ if remaining >= 100, pointsPerHour == 0 { return .noUsage }
+ return margin(projected)
+ }
+
+ private func margin(_ value: Double) -> Verdict {
+ value < Self.tightMargin ? .tight(margin: value) : .lasts(margin: value)
+ }
+
+ public init(
+ provider: UsageProvider, windowID: String, displayName: String?, start: Date, reset: Date, now: Date,
+ points: [ForecastPoint], remaining: Double, boosts: [ForecastPoint], away: [DateInterval] = [],
+ pointsPerHour: Double?, recentPointsPerHour: Double?
+ ) {
+ self.provider = provider
+ self.windowID = windowID
+ self.displayName = displayName
+ self.start = start
+ self.reset = reset
+ self.now = now
+ self.points = points
+ self.remaining = remaining
+ self.boosts = boosts
+ self.away = away
+ self.pointsPerHour = pointsPerHour
+ self.recentPointsPerHour = recentPointsPerHour
+ }
+
+ public static func make(
+ provider: UsageProvider, window: QuotaWindow, rows: [HistoryQuotaRow], now: Date,
+ accountFingerprint: String? = nil
+ ) -> QuotaForecast? {
+ guard let reset = window.resetsAt, let minutes = window.durationMinutes, minutes > 0, reset > now,
+ window.usedPercentage.isFinite
+ else { return nil }
+ let start = reset.addingTimeInterval(-Double(minutes) * 60)
+ guard start < now else { return nil }
+ let remaining = min(max(window.remainingPercentage, 0), 100)
+ let candidates = rows.filter { matches($0, provider: provider, window: window, now: now) }
+ .sorted { $0.sampledAt < $1.sampledAt }
+ let owns = { (row: HistoryQuotaRow) in
+ accountFingerprint == nil || row.accountFingerprint == nil || row.accountFingerprint == accountFingerprint
+ }
+ let matching = candidates.filter(owns)
+ let away = awayIntervals(candidates, owns: owns, now: now)
+ let observed = matching.filter { $0.sampledAt > start && sameReset($0.resetsAt, reset) }.map(point)
+ let origin = ForecastPoint(date: start, remaining: 100)
+ let current = ForecastPoint(date: now, remaining: remaining)
+ let points = [origin] + observed + [current]
+ let boosts = zip(points, points.dropFirst()).compactMap { previous, next in
+ next.remaining - previous.remaining > boostThreshold && !crosses(away, previous, next) ? next : nil
+ }
+ let horizon = Horizon.of(durationMinutes: minutes)
+ let previous = horizon.spansPreviousWindows ? matching.filter { $0.sampledAt < start }.map(point) : []
+ let timeline = previous + points
+ return QuotaForecast(
+ provider: provider, windowID: window.id, displayName: window.displayName, start: start, reset: reset,
+ now: now, points: points, remaining: remaining, boosts: boosts, away: away,
+ pointsPerHour: pace(timeline, away: away, span: horizon.pace, minimum: horizon.minimumPace, now: now),
+ recentPointsPerHour: pace(
+ timeline, away: away, span: horizon.recent, minimum: horizon.minimumRecent, now: now))
+ }
+
+ private static func matches(_ row: HistoryQuotaRow, provider: UsageProvider, window: QuotaWindow, now: Date) -> Bool
+ {
+ row.provider == provider && row.windowID == window.id && row.sampledAt < now && row.usedPercentage.isFinite
+ && (0...100).contains(row.usedPercentage)
+ }
+
+ private static func sameReset(_ rowReset: Date?, _ reset: Date) -> Bool {
+ rowReset.map { abs($0.timeIntervalSince(reset)) <= resetTolerance } ?? false
+ }
+
+ private static func point(_ row: HistoryQuotaRow) -> ForecastPoint {
+ ForecastPoint(date: row.sampledAt, remaining: 100 - row.usedPercentage)
+ }
+
+ static func pace(
+ _ timeline: [ForecastPoint], away: [DateInterval], span: TimeInterval, minimum: TimeInterval, now: Date
+ ) -> Double? {
+ guard let first = timeline.first else { return nil }
+ let cutoff = max(now.addingTimeInterval(-span), first.date)
+ let window =
+ [ForecastPoint(date: cutoff, remaining: interpolatedRemaining(timeline, at: cutoff))]
+ + timeline.filter { $0.date > cutoff }
+ let steps = zip(window, window.dropFirst()).filter { !crosses(away, $0.0, $0.1) }
+ let elapsed = steps.reduce(0) { $0 + $1.1.date.timeIntervalSince($1.0.date) }
+ guard elapsed >= minimum else { return nil }
+ let consumed = steps.reduce(0) { $0 + max(0, $1.0.remaining - $1.1.remaining) }
+ return consumed / (elapsed / 3600)
+ }
+
+ public static func crosses(_ away: [DateInterval], _ from: ForecastPoint, _ to: ForecastPoint) -> Bool {
+ away.contains { $0.start < to.date && $0.end > from.date }
+ }
+
+ static func awayIntervals(_ rows: [HistoryQuotaRow], owns: (HistoryQuotaRow) -> Bool, now: Date) -> [DateInterval] {
+ var intervals: [DateInterval] = []
+ var leftAt: Date?
+ for row in rows {
+ if !owns(row) {
+ leftAt = leftAt ?? row.sampledAt
+ } else if let start = leftAt {
+ intervals.append(DateInterval(start: start, end: row.sampledAt))
+ leftAt = nil
+ }
+ }
+ if let start = leftAt, start < now { intervals.append(DateInterval(start: start, end: now)) }
+ return intervals
+ }
+
+ public static func interpolatedRemaining(_ points: [ForecastPoint], at date: Date) -> Double {
+ guard let index = points.firstIndex(where: { $0.date >= date }) else { return points.last?.remaining ?? 100 }
+ guard index > 0 else { return points[index].remaining }
+ let before = points[index - 1]
+ let after = points[index]
+ let interval = after.date.timeIntervalSince(before.date)
+ guard interval > 0 else { return after.remaining }
+ return before.remaining + (after.remaining - before.remaining) * date.timeIntervalSince(before.date) / interval
+ }
+
+ private func exhaustion(pace: Double?) -> Date? {
+ guard let pace, pace > 0, remaining > 0 else { return nil }
+ let hours = remaining / pace
+ return hours < hoursLeft ? now.addingTimeInterval(hours * 3600) : nil
+ }
+}
diff --git a/Sources/TokenGaugeCore/ScanState.swift b/Sources/TokenGaugeCore/ScanState.swift
index 3c59afd..a940c04 100644
--- a/Sources/TokenGaugeCore/ScanState.swift
+++ b/Sources/TokenGaugeCore/ScanState.swift
@@ -14,9 +14,12 @@ public enum TranscriptScanner {
public struct Result: Sendable {
public let buckets: [ModelTokenBucket]
public let effortRecords: [EffortUsageRecord]
+ public let activityRecords: [ActivityUsageRecord]
public let bytesRead: Int
}
+ static let checkpointInterval: TimeInterval = 5
+
public static func stateURL(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
UsagePaths.supportDirectory(homeDirectory: homeDirectory).appending(path: "scan-state.json")
}
@@ -49,7 +52,7 @@ public enum TranscriptScanner {
let historyStart = windowStart(days: historyDays, now: now, calendar: calendar)
let effortStart = windowStart(days: effortDays, now: now, calendar: calendar)
guard let readStart = [historyStart, effortStart].compactMap({ $0?.timeIntervalSince1970 }).min() else {
- return Result(buckets: [], effortRecords: [], bytesRead: 0)
+ return Result(buckets: [], effortRecords: [], activityRecords: [], bytesRead: 0)
}
let loaded = stateURL.flatMap { ScanState.load(from: $0, windowStart: readStart) }
let previous = loaded?.state
@@ -77,6 +80,14 @@ public enum TranscriptScanner {
let parser = TranscriptLineParser()
var bytesRead = 0
var written = loaded?.encoded
+ var checkpointed = ProcessInfo.processInfo.systemUptime
+ func checkpoint() {
+ guard let stateURL else { return }
+ var partial = state
+ partial.files.merge(previous?.files ?? [:]) { current, _ in current }
+ persist(partial, to: stateURL, encoded: &written)
+ checkpointed = ProcessInfo.processInfo.systemUptime
+ }
for root in roots {
for (file, attributes) in candidateFiles(root: root.url, wanted: root.wanted) {
let key = file.path
@@ -85,14 +96,16 @@ public enum TranscriptScanner {
parser: parser, prune: readStart)
bytesRead += scanned.bytesRead
state.files[key] = scanned.file
+ if ProcessInfo.processInfo.systemUptime - checkpointed >= checkpointInterval { checkpoint() }
}
- if let stateURL { persist(state, to: stateURL, encoded: &written) }
+ checkpoint()
}
let historyCutoff = historyStart?.timeIntervalSince1970
let effortCutoff = effortStart?.timeIntervalSince1970
var historyEntries: [String: ScanEntry] = [:]
var effortEntries: [String: (provider: UsageProvider, entry: ScanEntry)] = [:]
+ var activityEntries: [String: (provider: UsageProvider, entry: ActivityEntry)] = [:]
for key in state.files.keys.sorted() {
guard let file = state.files[key] else { continue }
let recent = recentCodexDirectories.contains(dayDirectory(URL(filePath: key).deletingLastPathComponent()))
@@ -108,6 +121,10 @@ public enum TranscriptScanner {
effortEntries[entry.id] = (file.provider, merged)
}
}
+ guard includeEffort else { continue }
+ for entry in file.activities {
+ activityEntries[entry.id] = (file.provider, activityEntries[entry.id]?.entry.merging(entry) ?? entry)
+ }
}
if let stateURL { persist(state, to: stateURL, encoded: &written) }
@@ -118,6 +135,9 @@ public enum TranscriptScanner {
effortRecords: effortStart.map {
EffortUsageScanner.records(effortEntries.values, cutoff: $0, now: now, calendar: calendar)
} ?? [],
+ activityRecords: effortStart.map {
+ ActivityUsageScanner.records(activityEntries.values, cutoff: $0, now: now, calendar: calendar)
+ } ?? [],
bytesRead: bytesRead)
}
@@ -178,13 +198,18 @@ public enum TranscriptScanner {
if let reusable, reusable.size == attributes.size, reusable.modified == attributes.modified {
return (reusable, 0)
}
+ let claudeSubagent = provider == .claude && url.pathComponents.contains("subagents")
var entries: [String: ScanEntry] = [:]
+ var activities: [String: ActivityEntry] = [:]
+ var subagent = claudeSubagent
var context: CodexTurnContext?
var offset = 0
if let reusable, attributes.size > reusable.size, attributes.modified >= reusable.modified,
((try? JSONLReader.anchor(url, endingAt: reusable.offset)) ?? nil) == reusable.anchor
{
entries = Dictionary(reusable.entries.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
+ activities = Dictionary(reusable.activities.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
+ subagent = reusable.subagent
context = reusable.context
offset = reusable.offset
}
@@ -192,18 +217,31 @@ public enum TranscriptScanner {
autoreleasepool {
guard let metadata = parser.metadata(line) else { return }
let entry: ScanEntry?
+ let found: [ActivityEntry]
switch provider {
case .claude:
guard let date = parser.date(metadata.timestamp) else { return }
+ found = ActivityUsageScanner.claude(
+ metadata, line: line, at: date, subagent: subagent, parser: parser)
entry = ClaudeHistoryScanner.entry(from: metadata, at: date)
case .codex:
if metadata.type == "turn_context" {
context = metadata.payload.map(CodexTurnContext.init)
return
}
+ if metadata.type == "session_meta" {
+ subagent = metadata.payload?.source?.isSubagent == true
+ return
+ }
guard let date = parser.date(metadata.timestamp) else { return }
+ found = ActivityUsageScanner.codex(
+ metadata, line: line, at: date, rollout: url.lastPathComponent, subagent: subagent,
+ parser: parser)
entry = EffortUsageScanner.entry(from: metadata, at: date, context: context)
}
+ for activity in found {
+ activities[activity.id] = activities[activity.id]?.merging(activity) ?? activity
+ }
guard let entry else { return }
entries[entry.id] = entries[entry.id]?.merging(entry) ?? entry
}
@@ -213,16 +251,21 @@ public enum TranscriptScanner {
progress = try JSONLReader.read(url, from: offset, lineHandler: handler)
} catch JSONLReader.ReadError.offsetNotAtLineBoundary {
entries = [:]
+ activities = [:]
+ subagent = claudeSubagent
context = nil
progress = try JSONLReader.read(url, lineHandler: handler)
}
let kept = entries.values.filter { $0.peakAt >= prune }.sorted {
$0.firstAt == $1.firstAt ? $0.id < $1.id : $0.firstAt < $1.firstAt
}
+ let keptActivities = activities.values.filter { $0.at >= prune }.sorted {
+ $0.at == $1.at ? $0.id < $1.id : $0.at < $1.at
+ }
let file = ScanFile(
provider: provider, size: attributes.size, modified: attributes.modified, created: attributes.created,
offset: progress.committedOffset, anchor: try JSONLReader.anchor(url, endingAt: progress.committedOffset),
- context: context, entries: kept)
+ context: context, entries: kept, subagent: subagent, activities: keptActivities)
return (file, progress.bytesRead)
}
}
@@ -241,6 +284,19 @@ struct TranscriptLineParser {
try? decoder.decode(EffortTranscriptMetadata.self, from: line)
}
+ func skillCalls(_ line: Data) -> EffortTranscriptMetadata.SkillCalls? {
+ guard line.range(of: Self.skillMarker) != nil else { return nil }
+ return try? decoder.decode(EffortTranscriptMetadata.SkillCalls.self, from: line)
+ }
+
+ func toolCall(_ line: Data) -> EffortTranscriptMetadata.ToolCall? {
+ guard line.range(of: Self.skillFileMarker) != nil else { return nil }
+ return try? decoder.decode(EffortTranscriptMetadata.ToolCall.self, from: line)
+ }
+
+ private static let skillMarker = Data("\"Skill\"".utf8)
+ private static let skillFileMarker = Data("SKILL.md".utf8)
+
func date(_ timestamp: String?) -> Date? {
guard let timestamp else { return nil }
return fractional.date(from: timestamp) ?? standard.date(from: timestamp)
@@ -248,7 +304,7 @@ struct TranscriptLineParser {
}
struct ScanState: Codable {
- static let currentVersion = 2
+ static let currentVersion = 3
var version: Int
var windowStart: TimeInterval
@@ -272,6 +328,23 @@ struct ScanFile: Codable {
var anchor: String?
var context: CodexTurnContext?
var entries: [ScanEntry]
+ var subagent: Bool
+ var activities: [ActivityEntry]
+}
+
+struct ActivityEntry: Codable, Equatable {
+ var id: String
+ var at: TimeInterval
+ var kind: HistoryActivityKind
+ var key: String
+ var value: Int
+
+ func merging(_ incoming: ActivityEntry) -> ActivityEntry {
+ var merged = self
+ merged.at = min(at, incoming.at)
+ merged.value = max(value, incoming.value)
+ return merged
+ }
}
struct CodexTurnContext: Codable, Equatable {
@@ -295,6 +368,7 @@ struct ScanEntry: Equatable {
var effortModel: String
var effort: String
var eligible: Bool
+ var cachedTokens: Int?
func merging(_ incoming: ScanEntry) -> ScanEntry {
var merged = self
@@ -307,6 +381,7 @@ struct ScanEntry: Equatable {
if effortModel == "unknown" { merged.effortModel = incoming.effortModel }
if effort == "unknown" { merged.effort = incoming.effort }
merged.eligible = eligible || incoming.eligible
+ merged.cachedTokens = [cachedTokens, incoming.cachedTokens].compactMap { $0 }.max()
return merged
}
}
@@ -322,6 +397,7 @@ extension ScanEntry: Codable {
effortModel = try container.decode(String.self)
effort = try container.decode(String.self)
eligible = try container.decode(Bool.self)
+ cachedTokens = try container.decodeIfPresent(Int.self)
}
func encode(to encoder: Encoder) throws {
@@ -334,5 +410,6 @@ extension ScanEntry: Codable {
try container.encode(effortModel)
try container.encode(effort)
try container.encode(eligible)
+ try container.encode(cachedTokens)
}
}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift
index 92d347a..a6488c7 100644
--- a/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift
@@ -25,14 +25,17 @@ extension UsageHistoryStore {
static func writeEffort(_ connection: SQLiteConnection, record: EffortUsageRecord) throws {
let sql = """
- INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens, cached_tokens)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
day = CASE WHEN excluded.occurred_at < effort_events.occurred_at THEN excluded.day ELSE effort_events.day END,
occurred_at = MIN(effort_events.occurred_at, excluded.occurred_at),
model = CASE WHEN effort_events.model = 'unknown' THEN excluded.model ELSE effort_events.model END,
effort = CASE WHEN effort_events.effort = 'unknown' THEN excluded.effort ELSE effort_events.effort END,
- tokens = MAX(effort_events.tokens, excluded.tokens)
+ tokens = MAX(effort_events.tokens, excluded.tokens),
+ cached_tokens = MAX(
+ COALESCE(effort_events.cached_tokens, excluded.cached_tokens),
+ COALESCE(excluded.cached_tokens, effort_events.cached_tokens))
WHERE effort_events.provider = excluded.provider;
"""
try connection.run(sql) { statement in
@@ -43,6 +46,74 @@ extension UsageHistoryStore {
statement.bind(5, record.model)
statement.bind(6, record.effort)
statement.bind(7, Int64(record.tokens))
+ statement.bind(8, record.cachedTokens.map(Int64.init))
+ }
+ }
+
+ static func writeActivity(_ connection: SQLiteConnection, record: ActivityUsageRecord) throws {
+ let sql = """
+ INSERT INTO activity_events (id, provider, occurred_at, day, kind, key, value)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
+ ON CONFLICT(id) DO UPDATE SET
+ day = CASE WHEN excluded.occurred_at < activity_events.occurred_at THEN excluded.day ELSE activity_events.day END,
+ occurred_at = MIN(activity_events.occurred_at, excluded.occurred_at),
+ value = MAX(activity_events.value, excluded.value)
+ WHERE activity_events.provider = excluded.provider AND activity_events.kind = excluded.kind;
+ """
+ try connection.run(sql) { statement in
+ statement.bind(1, record.id.lowercased())
+ statement.bind(2, record.provider.rawValue)
+ statement.bind(3, record.recordedAt.timeIntervalSince1970)
+ statement.bind(4, record.day)
+ statement.bind(5, record.kind.rawValue)
+ statement.bind(6, record.key)
+ statement.bind(7, Int64(record.value))
}
}
+
+ static func readActivityRows(
+ _ connection: SQLiteConnection, since day: String?, through lastDay: String?
+ ) throws -> [HistoryActivityRow] {
+ let sql = """
+ SELECT day, provider, kind, key, COUNT(*), SUM(value), MAX(value) FROM activity_events
+ WHERE day >= ?1 AND day <= ?2
+ GROUP BY day, provider, kind, key ORDER BY day, provider, kind, key;
+ """
+ var rows: [HistoryActivityRow] = []
+ try connection.query(sql) { statement in
+ statement.bind(1, day ?? "0000-01-01")
+ statement.bind(2, lastDay ?? "9999-12-31")
+ } each: { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(1)),
+ let kind = HistoryActivityKind(rawValue: statement.text(2))
+ else { return }
+ rows.append(
+ HistoryActivityRow(
+ day: statement.text(0), provider: provider, kind: kind, key: statement.text(3),
+ count: statement.int(4), total: statement.int(5), maximum: statement.int(6)))
+ }
+ return rows
+ }
+
+ static func readCachedRows(
+ _ connection: SQLiteConnection, since day: String?, through lastDay: String?
+ ) throws -> [HistoryCachedRow] {
+ let sql = """
+ SELECT day, provider, SUM(cached_tokens), SUM(tokens) FROM effort_events
+ WHERE cached_tokens IS NOT NULL AND day >= ?1 AND day <= ?2
+ GROUP BY day, provider ORDER BY day, provider;
+ """
+ var rows: [HistoryCachedRow] = []
+ try connection.query(sql) { statement in
+ statement.bind(1, day ?? "0000-01-01")
+ statement.bind(2, lastDay ?? "9999-12-31")
+ } each: { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(1)) else { return }
+ rows.append(
+ HistoryCachedRow(
+ day: statement.text(0), provider: provider, cachedTokens: statement.int(2),
+ tokens: statement.int(3)))
+ }
+ return rows
+ }
}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift
index 167b6f8..f1e97f5 100644
--- a/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift
@@ -43,6 +43,16 @@ extension UsageHistoryStore {
tokens INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS effort_events_day_provider ON effort_events(day, provider);
+ CREATE TABLE IF NOT EXISTS activity_events (
+ id TEXT NOT NULL PRIMARY KEY,
+ provider TEXT NOT NULL,
+ occurred_at REAL NOT NULL,
+ day TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ CREATE INDEX IF NOT EXISTS activity_events_day_provider ON activity_events(day, provider);
"""
static let observedTotalsViews = """
@@ -106,6 +116,13 @@ extension UsageHistoryStore {
if !paceColumns.contains("account_fingerprint") {
try connection.exec("ALTER TABLE pace_samples ADD COLUMN account_fingerprint TEXT;")
}
+ var effortColumns = Set()
+ try connection.query("PRAGMA table_info(effort_events);", bindings: { _ in }) { statement in
+ effortColumns.insert(statement.text(1))
+ }
+ if !effortColumns.contains("cached_tokens") {
+ try connection.exec("ALTER TABLE effort_events ADD COLUMN cached_tokens INTEGER;")
+ }
if try schemaVersion(connection) < Schema.version {
try connection.exec(Schema.observedTotalsViews)
try connection.exec("PRAGMA user_version = \(Schema.version);")
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore.swift b/Sources/TokenGaugeCore/UsageHistoryStore.swift
index ae3f6d0..e0f6f37 100644
--- a/Sources/TokenGaugeCore/UsageHistoryStore.swift
+++ b/Sources/TokenGaugeCore/UsageHistoryStore.swift
@@ -86,17 +86,35 @@ public enum UsageHistoryStore {
}
public static func recordEffort(
- _ records: [EffortUsageRecord], at url: URL = UsagePaths.history()
+ _ records: [EffortUsageRecord], activity: [ActivityUsageRecord] = [], at url: URL = UsagePaths.history()
) throws {
let valid = records.filter(\.isValid)
- guard !valid.isEmpty else { return }
+ let validActivity = activity.filter(\.isValid)
+ guard !valid.isEmpty || !validActivity.isEmpty else { return }
try write(url) { connection in
try connection.transaction {
for record in valid { try writeEffort(connection, record: record) }
+ for record in validActivity { try writeActivity(connection, record: record) }
}
}
}
+ public static func activityRows(
+ since day: String? = nil, through lastDay: String? = nil, at url: URL = UsagePaths.history()
+ ) throws -> [HistoryActivityRow] {
+ try read(url) { connection in
+ try readActivityRows(connection, since: day, through: lastDay)
+ }
+ }
+
+ public static func cachedTokenRows(
+ since day: String? = nil, through lastDay: String? = nil, at url: URL = UsagePaths.history()
+ ) throws -> [HistoryCachedRow] {
+ try read(url) { connection in
+ try readCachedRows(connection, since: day, through: lastDay)
+ }
+ }
+
public static func effortRows(
since day: String? = nil, through lastDay: String? = nil, at url: URL = UsagePaths.history()
) throws -> [HistoryEffortRow] {
diff --git a/Tests/TokenGaugeAppTests/AppPreferencesTests.swift b/Tests/TokenGaugeAppTests/AppPreferencesTests.swift
index 3f91094..29f9d58 100644
--- a/Tests/TokenGaugeAppTests/AppPreferencesTests.swift
+++ b/Tests/TokenGaugeAppTests/AppPreferencesTests.swift
@@ -17,6 +17,7 @@ final class AppPreferencesTests: XCTestCase {
XCTAssertEqual(AppPreferences.Key.showHourlyPace, "showHourlyPace")
XCTAssertEqual(AppPreferences.Key.showLunaReserve, "showLunaReserve")
XCTAssertEqual(AppPreferences.Key.hiddenClaudeWindows, "hiddenClaudeWindows")
+ XCTAssertEqual(AppPreferences.Key.collapsedStatsCards, "collapsedStatsCards")
XCTAssertEqual(AppPreferences.Key.claudeMenuBarSource, "claudeMenuBarSource")
XCTAssertEqual(AppPreferences.Key.claudeAutomaticRecovery, "claudeAutomaticRecovery")
XCTAssertEqual(AppPreferences.Key.claudeRecoveryNextAttempt, "claudeRecoveryNextAttempt")
@@ -29,6 +30,18 @@ final class AppPreferencesTests: XCTestCase {
XCTAssertEqual(AppPreferences.Key.cancellationDailyBaseline(.codex), "codexCancellationDailyBaseline")
}
+ func testCollapsedStatsCardsPersistAndIgnoreUnknownValues() {
+ withDefaults { defaults in
+ let store = UsageStore(defaults: defaults)
+ store.toggleStatsCard(.models)
+ store.toggleStatsCard(.efforts)
+ store.toggleStatsCard(.models)
+ XCTAssertEqual(UsageStore(defaults: defaults).collapsedStatsCards, [.efforts])
+ defaults.set(["bogus", "skills"], forKey: "collapsedStatsCards")
+ XCTAssertEqual(UsageStore(defaults: defaults).collapsedStatsCards, [.skills])
+ }
+ }
+
func testLegacyDefaultsAreReadAndWrittenWithTheSameShapes() {
withDefaults { defaults in
let cancelledAt = Date(timeIntervalSince1970: 1_800_000_000)
diff --git a/Tests/TokenGaugeAppTests/ModelActivityTests.swift b/Tests/TokenGaugeAppTests/ModelActivityTests.swift
index d97f96d..b57875f 100644
--- a/Tests/TokenGaugeAppTests/ModelActivityTests.swift
+++ b/Tests/TokenGaugeAppTests/ModelActivityTests.swift
@@ -17,20 +17,20 @@ final class ModelActivityTests: XCTestCase {
excludingFamilies: ["fable"]
)
- XCTAssertEqual(chips.map(\.displayName), ["Opus", "Sonnet"])
+ XCTAssertEqual(chips.map(\.displayName), ["Opus 5", "Sonnet 5"])
}
func testScopedChipsStillReportTheirOwnFamily() {
let chips = ModelActivity.chips(buckets: buckets, since: start, limit: 2, family: "Fable")
- XCTAssertEqual(chips.map(\.displayName), ["Fable"])
+ XCTAssertEqual(chips.map(\.displayName), ["Fable 5"])
XCTAssertEqual(chips.map(\.tokens), [78_800_000])
}
func testNoExclusionKeepsEveryFamily() {
let chips = ModelActivity.chips(buckets: buckets, since: start, limit: 3)
- XCTAssertEqual(chips.map(\.displayName), ["Fable", "Opus", "Sonnet"])
+ XCTAssertEqual(chips.map(\.displayName), ["Fable 5", "Opus 5", "Sonnet 5"])
}
private var buckets: [ModelTokenBucket] {
diff --git a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
index 12160a3..d68598d 100644
--- a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
+++ b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
@@ -100,6 +100,34 @@ final class PopoverLayoutTests: XCTestCase {
}
}
+ func testPanelKeepsItsBottomPaddingInEveryMode() throws {
+ let suite = "TokenGauge.layout.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let store = UsageStore(
+ defaults: defaults, initialSnapshots: [codexSnapshot(), claudeSnapshot()], historyReadsEnabled: false)
+ for mode in UsageDisplayMode.allCases {
+ for history in [HistoryMode.recent, .calendar] {
+ store.displayMode = mode
+ store.historyMode = history
+ let bitmap = try render(PopoverView(store: store, showSettings: {}, showAbout: {}))
+ let scale = CGFloat(bitmap.pixelsHigh) / bitmap.size.height
+ let rows = Int((Theme.Layout.panelBottomPadding - 2) * scale)
+ let reference = try XCTUnwrap(
+ bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh - 1)?.usingColorSpace(.sRGB))
+ let touched = ((bitmap.pixelsHigh - rows).. 0.05
+ }
+ }
+ XCTAssertFalse(touched, "\(mode.rawValue) / \(history.rawValue)")
+ }
+ }
+ }
+
private func claudeSnapshot() -> ProviderUsageSnapshot {
Fixture.snapshot(
.claude,
diff --git a/Tests/TokenGaugeAppTests/ReadmeScreenshotTests.swift b/Tests/TokenGaugeAppTests/ReadmeScreenshotTests.swift
index 17ee2b7..8b2dc41 100644
--- a/Tests/TokenGaugeAppTests/ReadmeScreenshotTests.swift
+++ b/Tests/TokenGaugeAppTests/ReadmeScreenshotTests.swift
@@ -68,4 +68,102 @@ final class ReadmeScreenshotTests: XCTestCase {
PopoverView(store: store, showSettings: {}, showAbout: {}, history: history),
to: output.appendingPathComponent("readme-year.png"))
}
+
+ func testExportStats() throws {
+ guard let directory = ProcessInfo.processInfo.environment["TOKENGAUGE_SCREENSHOT_DIR"] else {
+ throw XCTSkip("Set TOKENGAUGE_SCREENSHOT_DIR to export the README stats")
+ }
+ let output = URL(fileURLWithPath: directory)
+ try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
+ let language = LocalizationManager.shared.language
+ let suite = "TokenGauge.readme.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer {
+ defaults.removePersistentDomain(forName: suite)
+ LocalizationManager.shared.language = language
+ }
+ LocalizationManager.shared.language = .english
+ let calendar = Calendar.current
+ let now = Date()
+ let today = calendar.startOfDay(for: now)
+ let weeklyReset = today.addingTimeInterval(2 * 86_400 + 10 * 3600)
+ let sessionReset = now.addingTimeInterval(2 * 3600 + 40 * 60)
+ var days: [HistoryCalendarDay] = []
+ var tokens: [HistoryTokenRow] = []
+ var efforts: [HistoryEffortRow] = []
+ var activity: [HistoryActivityRow] = []
+ var cached: [HistoryCachedRow] = []
+ let efforts4 = ["high", "xhigh", "medium", "max"]
+ let skills = ["code-review", "release-notes", "test-runner", "docs-writer", "ui-polish"]
+ for offset in -64...0 {
+ let date = calendar.date(byAdding: .day, value: offset, to: today)!
+ let key = HistoryDashboardModel.dayKey(date)
+ let index = offset + 64
+ let weekend = calendar.isDateInWeekend(date)
+ let base = ((index * 37 + 13) % 23 + 8) * (weekend ? 4_000_000 : 11_000_000)
+ let total = offset == 0 ? 412_000_000 : base
+ let split = [
+ ("claude-opus-5-5", total * 62 / 100), ("claude-fable-5-1", total * 24 / 100),
+ ("claude-sonnet-5", total * 11 / 100), ("claude-haiku-4-5", total * 3 / 100),
+ ]
+ let rows = split.map { HistoryTokenRow(day: key, provider: .claude, model: $0.0, tokens: $0.1) }
+ let effortRows = efforts4.enumerated().map { position, effort in
+ HistoryEffortRow(
+ day: key, provider: .claude, model: "claude-opus-5-5", effort: effort,
+ tokens: total * [40, 27, 21, 12][position] / 100)
+ }
+ days.append(HistoryCalendarDay(id: key, date: date, totals: [.claude: total], efforts: effortRows))
+ tokens += rows
+ efforts += effortRows
+ cached.append(HistoryCachedRow(day: key, provider: .claude, cachedTokens: total * 93 / 100, tokens: total))
+ for (position, skill) in skills.enumerated() where (index + position) % (position + 2) == 0 {
+ activity.append(
+ HistoryActivityRow(
+ day: key, provider: .claude, kind: .skill, key: skill, count: 5 - position,
+ total: 5 - position, maximum: 1))
+ }
+ activity.append(
+ HistoryActivityRow(
+ day: key, provider: .claude, kind: .turn, key: "", count: 18,
+ total: (38 + index % 20) * 60_000, maximum: (9 + index % 14) * 60_000))
+ }
+ func quota(_ id: String, minutes: Int, reset: Date, samples: [(TimeInterval, Double)]) -> [HistoryQuotaRow] {
+ samples.map {
+ HistoryQuotaRow(
+ sampledAt: now.addingTimeInterval($0.0), provider: .claude, windowID: id, displayName: nil,
+ usedPercentage: $0.1, resetsAt: reset, durationMinutes: minutes, accountFingerprint: nil)
+ }
+ }
+ let weeklyStart = weeklyReset.addingTimeInterval(-7 * 86_400)
+ var used = 0.0
+ let weeklySamples = stride(from: weeklyStart.timeIntervalSince(now) + 1800, to: 0, by: 1800).map {
+ offset -> (TimeInterval, Double) in
+ let hour = calendar.component(.hour, from: now.addingTimeInterval(offset))
+ used += (10...23).contains(hour) ? 0.36 : 0.02
+ return (offset, used)
+ }
+ let sessionSamples = stride(from: -2.3 * 3600, to: 0, by: 900).map { ($0, (2.3 + $0 / 3600) * 7.4) }
+ let records = StatsModel.Records(
+ input: StatsSummary.Input(
+ days: days, tokens: tokens, efforts: efforts, activity: activity, cached: cached),
+ quota: quota("seven_day", minutes: 10_080, reset: weeklyReset, samples: weeklySamples)
+ + quota("five_hour", minutes: 300, reset: sessionReset, samples: sessionSamples))
+ let windows = [
+ Fixture.window(id: "five_hour", usedPercentage: 17, resetsAt: sessionReset, durationMinutes: 300),
+ Fixture.window(
+ id: "seven_day", usedPercentage: weeklySamples.last?.1 ?? 40, resetsAt: weeklyReset,
+ durationMinutes: 10_080),
+ ]
+ let store = Fixture.store(
+ defaults: defaults, snapshots: [Fixture.snapshot(.claude, windows: windows, capturedAt: now)])
+ store.displayMode = .claude
+ store.animateChanges = false
+ for kind in QuotaWindowKind.allCases { store.setClaudeWindow(kind, visible: true) }
+ let view = StatsView(store: store, model: StatsModel(preview: records), providers: [.claude], scrolls: false)
+ .frame(width: Theme.Layout.panelWidth - Theme.Layout.panelPadding * 2)
+ .padding(Theme.Layout.panelPadding)
+ .background(Theme.panelBackground)
+ .environment(\.quotaAnimationsEnabled, false)
+ try render(view, to: output.appendingPathComponent("stats.png"), maximumHeight: 2400)
+ }
}
diff --git a/Tests/TokenGaugeAppTests/StatsSummaryTests.swift b/Tests/TokenGaugeAppTests/StatsSummaryTests.swift
new file mode 100644
index 0000000..0441549
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/StatsSummaryTests.swift
@@ -0,0 +1,75 @@
+import TokenGaugeCore
+import XCTest
+
+@testable import TokenGaugeApp
+
+@MainActor
+final class StatsSummaryTests: XCTestCase {
+ private let calendar = Calendar.current
+ private var now: Date { calendar.date(bySettingHour: 16, minute: 0, second: 0, of: Date()) ?? Date() }
+
+ private func input(days range: ClosedRange, today: Int, extraModel: String? = nil) -> StatsSummary.Input {
+ let start = calendar.startOfDay(for: now)
+ var days: [HistoryCalendarDay] = []
+ var tokens: [HistoryTokenRow] = []
+ for offset in range {
+ let date = calendar.date(byAdding: .day, value: offset, to: start) ?? start
+ let key = HistoryDashboardModel.dayKey(date)
+ let total = offset == 0 ? today : 100
+ days.append(HistoryCalendarDay(id: key, date: date, totals: [.claude: total], efforts: []))
+ tokens.append(HistoryTokenRow(day: key, provider: .claude, model: "claude-opus-5-5", tokens: total))
+ }
+ if let extraModel {
+ tokens.append(
+ HistoryTokenRow(
+ day: HistoryDashboardModel.dayKey(start), provider: .claude, model: extraModel, tokens: 1))
+ }
+ return StatsSummary.Input(days: days, tokens: tokens, efforts: [])
+ }
+
+ func testSpikeAgainstTypicalDay() {
+ let summary = StatsSummary.make(input(days: -40...0, today: 300), providers: [.claude], now: now)
+ XCTAssertEqual(summary.typicalDay, 100)
+ XCTAssertEqual(summary.today, 300)
+ guard case .spike(let ratio) = summary.insight else { return XCTFail("\(String(describing: summary.insight))") }
+ XCTAssertEqual(ratio, 3, accuracy: 0.001)
+ }
+
+ func testNewModelWinsOverSpike() {
+ let summary = StatsSummary.make(
+ input(days: -40...0, today: 300, extraModel: "claude-fable-5-1"), providers: [.claude], now: now)
+ guard case .newModel(let name) = summary.insight else {
+ return XCTFail("\(String(describing: summary.insight))")
+ }
+ XCTAssertEqual(name, "Fable 5.1")
+ }
+
+ func testDeltasNeedFullPreviousPeriod() {
+ let short = StatsSummary.make(input(days: -2...0, today: 100), providers: [.claude], now: now)
+ XCTAssertNil(short.previousWeek)
+ XCTAssertNil(short.previousMonth)
+ XCTAssertNil(short.typicalDay)
+ let long = StatsSummary.make(input(days: -70...0, today: 100), providers: [.claude], now: now)
+ XCTAssertNotNil(long.previousWeek)
+ XCTAssertNotNil(long.previousMonth)
+ XCTAssertEqual(long.previousWeek, long.week)
+ }
+
+ func testSkillsAndTurnsFollowSelectedProviders() {
+ var data = input(days: -10...0, today: 100)
+ let today = HistoryDashboardModel.dayKey(now)
+ data.activity = [
+ HistoryActivityRow(
+ day: today, provider: .claude, kind: .skill, key: "memory-closeout", count: 3, total: 3, maximum: 1),
+ HistoryActivityRow(
+ day: today, provider: .claude, kind: .skill, key: "iabrain-board", count: 5, total: 5, maximum: 1),
+ HistoryActivityRow(
+ day: today, provider: .codex, kind: .skill, key: "agent-delegation", count: 9, total: 9, maximum: 1),
+ HistoryActivityRow(
+ day: today, provider: .claude, kind: .turn, key: "", count: 2, total: 90_000, maximum: 60_000),
+ ]
+ let summary = StatsSummary.make(data, providers: [.claude], now: now)
+ XCTAssertEqual(summary.skills.map(\.label), ["iabrain-board", "memory-closeout"])
+ XCTAssertEqual(summary.turns, StatsTurns(todayMilliseconds: 90_000, todayCount: 2, longestMilliseconds: 60_000))
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift b/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
index cb85753..0a98ecf 100644
--- a/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
+++ b/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
@@ -37,10 +37,20 @@ final class WindowVisibilityTests: XCTestCase {
}
@MainActor
- func testModelDisplayNamesCollapseToFamilies() {
- XCTAssertEqual(ModelActivity.displayName("claude-fable-5"), "Fable")
- XCTAssertEqual(ModelActivity.displayName("claude-opus-5[1m]"), "Opus")
- XCTAssertEqual(ModelActivity.displayName("claude-haiku-4-5-20251001"), "Haiku")
+ func testModelFamiliesCollapseVersions() {
+ XCTAssertEqual(ModelActivity.family("claude-fable-5"), "Fable")
+ XCTAssertEqual(ModelActivity.family("claude-opus-5[1m]"), "Opus")
+ XCTAssertEqual(ModelActivity.family("claude-haiku-4-5-20251001"), "Haiku")
+ }
+
+ @MainActor
+ func testModelDisplayNamesKeepTheVersion() {
+ XCTAssertEqual(ModelActivity.displayName("claude-opus-5-5"), "Opus 5.5")
+ XCTAssertEqual(ModelActivity.displayName("claude-opus-5[1m]"), "Opus 5")
+ XCTAssertEqual(ModelActivity.displayName("claude-fable-5-1"), "Fable 5.1")
+ XCTAssertEqual(ModelActivity.displayName("claude-haiku-4-5-20251001"), "Haiku 4.5")
+ XCTAssertEqual(ModelActivity.displayName("gpt-6-astra"), "GPT-6 Astra")
+ XCTAssertEqual(ModelActivity.displayName("gpt-5.3-codex"), "GPT-5.3 Codex")
}
@MainActor
@@ -54,7 +64,7 @@ final class WindowVisibilityTests: XCTestCase {
let chips = ModelActivity.chips(buckets: buckets, since: start)
- XCTAssertEqual(chips.map(\.displayName), ["Opus", "Fable"])
+ XCTAssertEqual(chips.map(\.displayName), ["Opus 5", "Fable 5"])
XCTAssertEqual(chips.map(\.tokens), [400, 100])
}
diff --git a/Tests/TokenGaugeCoreTests/ActivityUsageScannerTests.swift b/Tests/TokenGaugeCoreTests/ActivityUsageScannerTests.swift
new file mode 100644
index 0000000..b3cf7ef
--- /dev/null
+++ b/Tests/TokenGaugeCoreTests/ActivityUsageScannerTests.swift
@@ -0,0 +1,260 @@
+import Foundation
+import SQLite3
+import XCTest
+
+@testable import TokenGaugeCore
+
+final class ActivityUsageScannerTests: XCTestCase {
+ private let now = ISO8601DateFormatter().date(from: "2026-09-12T18:00:00Z")!
+
+ func testClaudeSkillToolUseCountsButSkillTextDoesNot() throws {
+ try withHome { home in
+ try write(
+ home, ".claude/projects/project/a.jsonl",
+ [
+ claudeSkill("m1", tool: "t1", skill: "memory-closeout"),
+ claudeSkill("m1", tool: "t1", skill: "memory-closeout"),
+ claudeSkill("m2", tool: "t2", skill: "anthropic-skills:pdf"),
+ claudeSkill("m3", tool: "t3", skill: "Bad Name"),
+ #"""
+ {"type":"assistant","timestamp":"2026-09-12T12:00:00.000Z","message":{"id":"m4","content":[{"type":"text","text":"call {\"type\":\"tool_use\",\"name\":\"Skill\",\"input\":{\"skill\":\"leak\"}}"}]}}
+ """#,
+ #"""
+ {"type":"user","timestamp":"2026-09-12T12:00:00.000Z","message":{"id":"m5","content":[{"type":"tool_use","id":"t5","name":"Skill","input":{"skill":"user-text"}}]}}
+ """#,
+ ])
+ let records = try scan(home).activityRecords
+ XCTAssertEqual(records.map(\.key).sorted(), ["anthropic-skills:pdf", "memory-closeout"])
+ XCTAssertTrue(records.allSatisfy { $0.kind == .skill && $0.value == 1 && $0.id.count == 64 })
+ }
+ }
+
+ func testCodexSkillReadCountsOncePerRolloutAndIgnoresSearchesAndEdits() throws {
+ try withHome { home in
+ let read = codexCall(
+ #"text(await tools.exec_command({cmd:\"cat /u/.agents/skills/memory-closeout/SKILL.md\"}))"#)
+ try write(
+ home, ".codex/sessions/2026/09/12/rollout-a.jsonl",
+ [
+ sessionMeta(#""cli""#), read, read,
+ codexCall(#"tools.exec_command({cmd:\"sed -n '1,80p' /u/skills/memory-closeout/SKILL.md\"})"#),
+ codexCall(#"tools.exec_command({cmd:\"rg -n 'a|b' /u/skills/search-only/SKILL.md\"})"#),
+ codexCall(#"tools.exec_command({cmd:\"rg skills/grep-only/SKILL.md\"})"#),
+ codexCall(
+ #"*** Begin Patch\n*** Update File: /u/skills/patched/SKILL.md\n@@\n-a\n+b\n*** End Patch"#,
+ name: "apply_patch"),
+ codexCall(#"tools.exec_command({cmd:\"apply_patch skills/patched-cmd/SKILL.md\"})"#),
+ #"""
+ {"type":"response_item","timestamp":"2026-09-12T12:00:00Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cd /u && head -n 5 skills/agent-delegation/SKILL.md\"}"}}
+ """#,
+ ])
+ try write(home, ".codex/sessions/2026/09/12/rollout-b.jsonl", [sessionMeta(#""cli""#), read])
+ let records = try scan(home).activityRecords
+ XCTAssertEqual(
+ records.map(\.key).sorted(), ["agent-delegation", "memory-closeout", "memory-closeout"])
+ XCTAssertEqual(Set(records.map(\.id)).count, 3)
+ }
+ }
+
+ func testSubagentAndSidechainTurnsAreSkipped() throws {
+ try withHome { home in
+ try write(
+ home, ".claude/projects/project/a.jsonl",
+ [claudeTurn("u1", duration: 5000), claudeTurn("u2", duration: 9000, sidechain: true)])
+ try write(
+ home, ".claude/projects/project/session/subagents/agent.jsonl", [claudeTurn("u3", duration: 7000)])
+ try write(
+ home, ".codex/sessions/2026/09/12/rollout-main.jsonl",
+ [sessionMeta(#""vscode""#), codexTurn("turn-main", duration: 3000)])
+ try write(
+ home, ".codex/sessions/2026/09/12/rollout-child.jsonl",
+ [sessionMeta(#"{"subagent":{"thread_spawn":{}}}"#), codexTurn("turn-child", duration: 4000)])
+ try write(
+ home, ".codex/sessions/2026/09/12/rollout-legacy.jsonl",
+ [sessionMeta(#""subagent""#), codexTurn("turn-legacy", duration: 4000)])
+ let records = try scan(home).activityRecords
+ XCTAssertEqual(records.map(\.value).sorted(), [3000, 5000])
+ XCTAssertTrue(records.allSatisfy { $0.kind == .turn && $0.key.isEmpty })
+ }
+ }
+
+ func testRepeatedScansKeepActivityAndEffortTotalsIdentical() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let database = home.appending(path: "support/history.sqlite")
+ try write(
+ home, ".claude/projects/project/a.jsonl",
+ [
+ claudeSkill("m1", tool: "t1", skill: "memory-closeout"), claudeTurn("u1", duration: 5000),
+ claudeUsage("m2", cached: 30),
+ ])
+ try write(
+ home, ".codex/sessions/2026/09/12/rollout-a.jsonl",
+ [
+ sessionMeta(#""cli""#), codexCall(#"tools.exec_command({cmd:\"cat skills/x/SKILL.md\"})"#),
+ codexTurn("turn-a", duration: 3000), codexUsage("r1", total: 100, cached: 60),
+ ])
+ let first = try scan(home, state: state)
+ try UsageHistoryStore.recordEffort(first.effortRecords, activity: first.activityRecords, at: database)
+ let activity = try UsageHistoryStore.activityRows(at: database)
+ let effort = try UsageHistoryStore.effortRows(at: database)
+ let cached = try UsageHistoryStore.cachedTokenRows(at: database)
+ let second = try scan(home, state: state)
+ XCTAssertEqual(second.bytesRead, 0)
+ try UsageHistoryStore.recordEffort(second.effortRecords, activity: second.activityRecords, at: database)
+ let full = try scan(home)
+ try UsageHistoryStore.recordEffort(full.effortRecords, activity: full.activityRecords, at: database)
+ XCTAssertEqual(activity.count, 4)
+ XCTAssertEqual(try UsageHistoryStore.activityRows(at: database), activity)
+ XCTAssertEqual(try UsageHistoryStore.effortRows(at: database), effort)
+ XCTAssertEqual(try UsageHistoryStore.cachedTokenRows(at: database), cached)
+ XCTAssertEqual(
+ cached.map { [$0.cachedTokens, $0.tokens] }, [[30, 90], [60, 100]])
+ }
+ }
+
+ func testCachedTokensKeepMaximumAndIgnoreUnknownRows() throws {
+ try withHome { home in
+ let database = home.appending(path: "history.sqlite")
+ try UsageHistoryStore.recordEffort([effort("a", tokens: 50, cached: 20)], at: database)
+ try UsageHistoryStore.recordEffort([effort("a", tokens: 40, cached: 10)], at: database)
+ try UsageHistoryStore.recordEffort([effort("a", tokens: 40, cached: nil)], at: database)
+ try UsageHistoryStore.recordEffort([effort("b", tokens: 70, cached: nil)], at: database)
+ XCTAssertEqual(
+ try UsageHistoryStore.cachedTokenRows(at: database),
+ [HistoryCachedRow(day: "2026-09-12", provider: .codex, cachedTokens: 20, tokens: 50)])
+ try UsageHistoryStore.recordEffort([effort("b", tokens: 70, cached: 35)], at: database)
+ XCTAssertEqual(try UsageHistoryStore.cachedTokenRows(at: database).first?.cachedTokens, 55)
+ }
+ }
+
+ func testActivityAggregatesAndRejectsInvalidRecords() throws {
+ try withHome { home in
+ let database = home.appending(path: "history.sqlite")
+ try UsageHistoryStore.recordEffort(
+ [],
+ activity: [
+ activity("a", kind: .turn, key: "", value: 1000),
+ activity("b", kind: .turn, key: "", value: 4000),
+ activity("b", kind: .turn, key: "", value: 3000),
+ activity("c", kind: .skill, key: "memory-closeout", value: 1),
+ activity("d", kind: .skill, key: "/private/path", value: 1),
+ activity("e", kind: .turn, key: "text", value: 1),
+ activity("f", kind: .turn, key: "", value: -1),
+ ], at: database)
+ XCTAssertEqual(
+ try UsageHistoryStore.activityRows(since: "2026-09-12", through: "2026-09-12", at: database),
+ [
+ HistoryActivityRow(
+ day: "2026-09-12", provider: .claude, kind: .skill, key: "memory-closeout", count: 1, total: 1,
+ maximum: 1),
+ HistoryActivityRow(
+ day: "2026-09-12", provider: .claude, kind: .turn, key: "", count: 2, total: 5000,
+ maximum: 4000),
+ ])
+ XCTAssertTrue(try UsageHistoryStore.activityRows(since: "2026-09-13", at: database).isEmpty)
+ }
+ }
+
+ func testOldSchemaDatabaseMigratesWithoutLosingEffortRows() throws {
+ try withHome { home in
+ let database = home.appending(path: "history.sqlite")
+ var handle: OpaquePointer?
+ XCTAssertEqual(sqlite3_open(database.path, &handle), SQLITE_OK)
+ let legacy = """
+ CREATE TABLE effort_events (
+ id TEXT NOT NULL PRIMARY KEY, provider TEXT NOT NULL, occurred_at REAL NOT NULL,
+ day TEXT NOT NULL, model TEXT NOT NULL, effort TEXT NOT NULL, tokens INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ INSERT INTO effort_events VALUES ('\(String(repeating: "a", count: 64))', 'codex', 100,
+ '2026-09-12', 'gpt-6', 'high', 40);
+ PRAGMA user_version = 1;
+ """
+ XCTAssertEqual(sqlite3_exec(handle, legacy, nil, nil, nil), SQLITE_OK)
+ sqlite3_close(handle)
+
+ XCTAssertTrue(try UsageHistoryStore.cachedTokenRows(at: database).isEmpty)
+ XCTAssertTrue(try UsageHistoryStore.activityRows(at: database).isEmpty)
+ try UsageHistoryStore.recordEffort(
+ [effort("a", tokens: 50, cached: 25)], activity: [activity("b", kind: .turn, key: "", value: 10)],
+ at: database)
+ XCTAssertEqual(try UsageHistoryStore.effortRows(at: database).map(\.tokens), [50])
+ XCTAssertEqual(try UsageHistoryStore.cachedTokenRows(at: database).first?.cachedTokens, 25)
+ XCTAssertEqual(try UsageHistoryStore.activityRows(at: database).first?.total, 10)
+ }
+ }
+
+ private func scan(_ home: URL, state: URL? = nil) throws -> TranscriptScanner.Result {
+ try TranscriptScanner.scan(homeDirectory: home, stateURL: state, now: now)
+ }
+
+ private func withHome(_ body: (URL) throws -> Void) throws {
+ let home = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appending(path: UUID().uuidString)
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ try body(home)
+ }
+
+ private func write(_ home: URL, _ path: String, _ lines: [String]) throws {
+ let file = home.appending(path: path)
+ try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try Data((lines.joined(separator: "\n") + "\n").utf8).write(to: file)
+ }
+
+ private func claudeSkill(_ message: String, tool: String, skill: String) -> String {
+ """
+ {"type":"assistant","timestamp":"2026-09-12T12:00:00.000Z","message":{"id":"\(message)","content":[{"type":"text","text":"synthetic"},{"type":"tool_use","id":"\(tool)","name":"Skill","input":{"skill":"\(skill)","args":"synthetic"}}]}}
+ """
+ }
+
+ private func claudeTurn(_ uuid: String, duration: Int, sidechain: Bool = false) -> String {
+ """
+ {"type":"system","subtype":"turn_duration","timestamp":"2026-09-12T12:00:00.000Z","uuid":"\(uuid)","isSidechain":\(sidechain),"durationMs":\(duration)}
+ """
+ }
+
+ private func claudeUsage(_ id: String, cached: Int) -> String {
+ """
+ {"type":"assistant","timestamp":"2026-09-12T12:00:00.000Z","message":{"id":"\(id)","model":"claude-fable-5-1","usage":{"input_tokens":10,"cache_creation_input_tokens":20,"cache_read_input_tokens":\(cached),"output_tokens":30}}}
+ """
+ }
+
+ private func sessionMeta(_ source: String) -> String {
+ """
+ {"type":"session_meta","timestamp":"2026-09-12T11:59:00Z","payload":{"id":"session","source":\(source)}}
+ """
+ }
+
+ private func codexCall(_ input: String, name: String = "exec") -> String {
+ """
+ {"type":"response_item","timestamp":"2026-09-12T12:00:00Z","payload":{"type":"custom_tool_call","name":"\(name)","input":"\(input)"}}
+ """
+ }
+
+ private func codexTurn(_ turn: String, duration: Int) -> String {
+ """
+ {"type":"event_msg","timestamp":"2026-09-12T12:10:00Z","payload":{"type":"task_complete","turn_id":"\(turn)","duration_ms":\(duration),"last_agent_message":"synthetic"}}
+ """
+ }
+
+ private func codexUsage(_ id: String, total: Int, cached: Int) -> String {
+ """
+ {"type":"token_usage_record","timestamp":"2026-09-12T12:05:00Z","payload":{"turn_id":"turn-a","response_id":"\(id)","usage":{"total_tokens":\(total),"cached_input_tokens":\(cached)}}}
+ """
+ }
+
+ private func effort(_ character: String, tokens: Int, cached: Int?) -> EffortUsageRecord {
+ EffortUsageRecord(
+ id: String(repeating: character, count: 64), provider: .codex, recordedAt: Date(timeIntervalSince1970: 100),
+ day: "2026-09-12", model: "gpt-6", effort: "high", tokens: tokens, cachedTokens: cached)
+ }
+
+ private func activity(_ character: String, kind: HistoryActivityKind, key: String, value: Int)
+ -> ActivityUsageRecord
+ {
+ ActivityUsageRecord(
+ id: String(repeating: character, count: 64), provider: .claude,
+ recordedAt: Date(timeIntervalSince1970: 1_789_214_400), day: "2026-09-12", kind: kind, key: key,
+ value: value)
+ }
+}
diff --git a/Tests/TokenGaugeCoreTests/ObservedHistoryTotalsTests.swift b/Tests/TokenGaugeCoreTests/ObservedHistoryTotalsTests.swift
index 5378c7c..bfffc63 100644
--- a/Tests/TokenGaugeCoreTests/ObservedHistoryTotalsTests.swift
+++ b/Tests/TokenGaugeCoreTests/ObservedHistoryTotalsTests.swift
@@ -27,7 +27,7 @@ final class ObservedHistoryTotalsTests: XCTestCase {
('2026-09-07','codex','model-b',70,0),
('2026-09-08','codex','all',300,0),
('2026-09-08','codex','model-a',20,0);
- INSERT INTO effort_events VALUES
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens) VALUES
('a','codex',1,'2026-09-07','model-a','high',100),
('b','codex',1,'2026-09-07','model-b','high',100),
('c','codex',1,'2026-09-08','model-a','high',250);
@@ -44,7 +44,7 @@ final class ObservedHistoryTotalsTests: XCTestCase {
func testEffortOnlyAndExplicitZeroDaysRemainPresentWhileMissingDaysStayAbsent() throws {
try sql(
"""
- INSERT INTO effort_events VALUES
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens) VALUES
('a','claude',1,'2026-09-07','fable','high',40),
('b','claude',1,'2026-09-08','fable','high',0);
INSERT INTO daily_tokens VALUES ('2026-09-10','claude','all',0,0);
@@ -62,7 +62,7 @@ final class ObservedHistoryTotalsTests: XCTestCase {
INSERT INTO daily_tokens VALUES
('2026-08-31','codex','all',10,0), ('2026-09-01','codex','all',20,0),
('2026-09-07','codex','all',30,0);
- INSERT INTO effort_events VALUES
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens) VALUES
('a','codex',1,'2026-09-01','gpt','high',25),
('b','claude',1,'2026-09-01','fable','high',40);
""")
@@ -83,7 +83,7 @@ final class ObservedHistoryTotalsTests: XCTestCase {
try sql(
"""
INSERT INTO daily_tokens VALUES ('2026-09-07','codex','all',100,0);
- INSERT INTO effort_events VALUES ('a','codex',1,'2026-09-07','gpt','high',150);
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens) VALUES ('a','codex',1,'2026-09-07','gpt','high',150);
DROP VIEW monthly_totals;
DROP VIEW weekly_totals;
DROP VIEW daily_totals;
diff --git a/Tests/TokenGaugeCoreTests/QuotaForecastTests.swift b/Tests/TokenGaugeCoreTests/QuotaForecastTests.swift
new file mode 100644
index 0000000..ba9ddbc
--- /dev/null
+++ b/Tests/TokenGaugeCoreTests/QuotaForecastTests.swift
@@ -0,0 +1,136 @@
+import XCTest
+
+@testable import TokenGaugeCore
+
+final class QuotaForecastTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+ private let hour: TimeInterval = 3600
+
+ private func row(
+ _ offset: Double, used: Double, reset: Date, account: String? = nil
+ ) -> HistoryQuotaRow {
+ HistoryQuotaRow(
+ sampledAt: now.addingTimeInterval(offset * hour), provider: .claude, windowID: "weekly",
+ displayName: nil, usedPercentage: used, resetsAt: reset, durationMinutes: 10_080,
+ accountFingerprint: account)
+ }
+
+ private func forecast(
+ used: Double, resetIn hours: Double, rows: [HistoryQuotaRow], account: String? = nil
+ ) -> QuotaForecast? {
+ let window = QuotaWindow(
+ id: "weekly", usedPercentage: used, resetsAt: now.addingTimeInterval(hours * hour),
+ durationMinutes: 10_080, displayName: nil)
+ return QuotaForecast.make(provider: .claude, window: window, rows: rows, now: now, accountFingerprint: account)
+ }
+
+ func testSteadyBurnRunsOutBeforeReset() throws {
+ let reset = now.addingTimeInterval(72 * hour)
+ let rows = (1...15).map { row(-96 + Double($0) * 6, used: 0.75 * Double($0) * 6, reset: reset) }
+ let foreign = row(-3, used: 99, reset: reset, account: "b")
+ let result = try XCTUnwrap(forecast(used: 72, resetIn: 72, rows: rows + [foreign], account: "a"))
+ XCTAssertEqual(result.points.count, 17)
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 0.75, accuracy: 0.001)
+ guard case .runsOut(let date) = result.verdict else { return XCTFail("\(result.verdict)") }
+ XCTAssertEqual(date.timeIntervalSince(now), 28 / 0.75 * hour, accuracy: 60)
+ }
+
+ func testEarlyResetKeepsConsumptionOnBothSides() throws {
+ let reset = now.addingTimeInterval(72 * hour)
+ let rows = (1...15).map { index -> HistoryQuotaRow in
+ let elapsed = Double(index) * 6
+ return row(-96 + elapsed, used: elapsed <= 60 ? elapsed : elapsed - 66, reset: reset)
+ }
+ let result = try XCTUnwrap(forecast(used: 30, resetIn: 72, rows: rows))
+ XCTAssertEqual(result.boosts.map(\.date), [now.addingTimeInterval(-30 * hour)])
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 66 / 72, accuracy: 0.001)
+ XCTAssertEqual(try XCTUnwrap(result.recentPointsPerHour), 1, accuracy: 0.001)
+ guard case .tight(let margin) = result.verdict else { return XCTFail("\(result.verdict)") }
+ XCTAssertEqual(margin, 4, accuracy: 0.01)
+ }
+
+ func testFlatRecentDaysLast() throws {
+ let reset = now.addingTimeInterval(48 * hour)
+ let rows = (1...19).map { row(-120 + Double($0) * 6, used: min(50, Double($0) * 12.5), reset: reset) }
+ let result = try XCTUnwrap(forecast(used: 50, resetIn: 48, rows: rows))
+ XCTAssertEqual(result.pointsPerHour, 0)
+ XCTAssertEqual(result.verdict, .lasts(margin: 50))
+ XCTAssertNil(result.runsOutAt)
+ }
+
+ func testUntouchedWindowHasNoUsage() throws {
+ let result = try XCTUnwrap(forecast(used: 0, resetIn: 144, rows: []))
+ XCTAssertEqual(result.verdict, .noUsage)
+ }
+
+ func testPaceSpansThePreviousWindow() throws {
+ let start = now.addingTimeInterval(-12 * hour)
+ let previous = (0...9).map { row(-72 + Double($0) * 6, used: 20 + Double($0) * 6, reset: start) }
+ let current = row(-6, used: 6, reset: now.addingTimeInterval(156 * hour))
+ let result = try XCTUnwrap(forecast(used: 12, resetIn: 156, rows: previous + [current]))
+ XCTAssertEqual(result.points.count, 3)
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 66 / 72, accuracy: 0.001)
+ }
+
+ func testShortWindowHasNoPace() throws {
+ let result = try XCTUnwrap(forecast(used: 2, resetIn: 166, rows: []))
+ XCTAssertNil(result.pointsPerHour)
+ XCTAssertNil(result.recentPointsPerHour)
+ XCTAssertEqual(result.verdict, .lasts(margin: 98))
+ }
+
+ private func sessionRow(_ offset: Double, used: Double, reset: Date) -> HistoryQuotaRow {
+ HistoryQuotaRow(
+ sampledAt: now.addingTimeInterval(offset * hour), provider: .claude, windowID: "five_hour",
+ displayName: nil, usedPercentage: used, resetsAt: reset, durationMinutes: 300, accountFingerprint: nil)
+ }
+
+ private func session(used: Double, resetIn hours: Double, rows: [HistoryQuotaRow] = []) -> QuotaForecast? {
+ let window = QuotaWindow(
+ id: "five_hour", usedPercentage: used, resetsAt: now.addingTimeInterval(hours * hour),
+ durationMinutes: 300, displayName: nil)
+ return QuotaForecast.make(provider: .claude, window: window, rows: rows, now: now)
+ }
+
+ func testSessionRunsOutBeforeItsReset() throws {
+ let result = try XCTUnwrap(session(used: 78, resetIn: 1.15))
+ XCTAssertTrue(result.isSession)
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 78 / 3.85, accuracy: 0.001)
+ guard case .runsOut(let date) = result.verdict else { return XCTFail("\(result.verdict)") }
+ XCTAssertEqual(date.timeIntervalSince(now), 22 / (78 / 3.85) * hour, accuracy: 1)
+ XCTAssertEqual(result.budget, 22 / 1.15, accuracy: 0.001)
+ }
+
+ func testSessionIgnoresThePreviousSession() throws {
+ let previous = (0...8).map {
+ sessionRow(-3 + Double($0) * 0.25, used: Double($0) * 10, reset: now.addingTimeInterval(-hour / 3))
+ }
+ let result = try XCTUnwrap(session(used: 10, resetIn: 5 - 1.0 / 3, rows: previous))
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 30, accuracy: 0.001)
+ XCTAssertNil(result.recentPointsPerHour)
+ XCTAssertEqual(result.points.count, 2)
+ }
+
+ func testSessionRecentHourShowsASlowdown() throws {
+ let reset = now.addingTimeInterval(2 * hour)
+ let rows = (1...12).map { sessionRow(-3 + Double($0) * 0.25, used: min(45, Double($0) * 7.5), reset: reset) }
+ let result = try XCTUnwrap(session(used: 45, resetIn: 2, rows: rows))
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 15, accuracy: 0.001)
+ XCTAssertEqual(try XCTUnwrap(result.recentPointsPerHour), 0, accuracy: 0.001)
+ XCTAssertEqual(result.verdict, .lasts(margin: 25))
+ }
+
+ func testOtherAccountTimeIsNeitherResetNorPace() throws {
+ let reset = now.addingTimeInterval(120 * hour)
+ let before = (30...47).map { row(-Double($0), used: 2 * (48 - Double($0)), reset: reset, account: "a") }
+ let other = (10...29).map { row(-Double($0) - 0.5, used: 80, reset: now, account: "b") }
+ let after = (1...9).map { row(-Double($0), used: 10 - Double($0), reset: reset, account: "a") }
+ let result = try XCTUnwrap(forecast(used: 10, resetIn: 120, rows: before + other + after, account: "a"))
+ XCTAssertEqual(
+ result.away,
+ [DateInterval(start: now.addingTimeInterval(-29.5 * hour), end: now.addingTimeInterval(-9 * hour))])
+ XCTAssertEqual(result.boosts, [])
+ XCTAssertEqual(try XCTUnwrap(result.pointsPerHour), 45 / 27, accuracy: 0.001)
+ XCTAssertEqual(try XCTUnwrap(result.recentPointsPerHour), 1, accuracy: 0.001)
+ }
+}
diff --git a/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift b/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift
index f2fa15c..ae83ce0 100644
--- a/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift
+++ b/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift
@@ -203,7 +203,7 @@ final class TranscriptScannerTests: XCTestCase {
var stored = try XCTUnwrap(
JSONSerialization.jsonObject(with: try Data(contentsOf: state)) as? [String: Any])
- XCTAssertEqual(stored["version"] as? Int, 2)
+ XCTAssertEqual(stored["version"] as? Int, 3)
stored["version"] = 1
try JSONSerialization.data(withJSONObject: stored).write(to: state)
XCTAssertEqual(try scan(home, state: state).bytesRead, total)
@@ -431,6 +431,7 @@ private enum ReferenceScanners {
let model: String?
let effort: String?
let tokens: Int?
+ let cached: Int?
if provider == .codex {
guard type == "token_usage_record", let payload = object["payload"] as? [String: Any] else {
continue
@@ -441,19 +442,22 @@ private enum ReferenceScanners {
model = matching?["model"] as? String
effort = matching?["effort"] as? String
tokens = (payload["usage"] as? [String: Any])?["total_tokens"] as? Int
+ cached = (payload["usage"] as? [String: Any])?["cached_input_tokens"] as? Int
} else {
guard type == "assistant", let message = object["message"] as? [String: Any] else { continue }
identifier = message["id"] as? String
model = message["model"] as? String
effort = object["perTurnEffort"] as? String ?? object["effort"] as? String
tokens = (message["usage"] as? [String: Any]).map(total)
+ cached = (message["usage"] as? [String: Any])?["cache_read_input_tokens"] as? Int
}
guard let identifier, !identifier.isEmpty, let tokens, tokens > 0 else { continue }
let id = SHA256.hash(data: Data("\(provider.rawValue):\(identifier)".utf8))
.map { String(format: "%02x", $0) }.joined()
let record = EffortUsageRecord(
id: id, provider: provider, recordedAt: date, day: day(date, calendar),
- model: normalizedModel(model), effort: normalizedEffort(effort), tokens: tokens)
+ model: normalizedModel(model), effort: normalizedEffort(effort), tokens: tokens,
+ cachedTokens: cached.map { max(0, $0) })
records[id] = merge(records[id], record)
}
}
@@ -468,7 +472,8 @@ private enum ReferenceScanners {
id: existing.id, provider: existing.provider, recordedAt: earliest.recordedAt, day: earliest.day,
model: existing.model == "unknown" ? incoming.model : existing.model,
effort: existing.effort == "unknown" ? incoming.effort : existing.effort,
- tokens: max(existing.tokens, incoming.tokens))
+ tokens: max(existing.tokens, incoming.tokens),
+ cachedTokens: [existing.cachedTokens, incoming.cachedTokens].compactMap { $0 }.max())
}
private static func normalizedModel(_ raw: String?) -> String {
diff --git a/docs/images/stats.png b/docs/images/stats.png
new file mode 100644
index 0000000..ae5c3be
Binary files /dev/null and b/docs/images/stats.png differ
diff --git a/scripts/usage_history.sh b/scripts/usage_history.sh
index d8d8b10..9da2488 100755
--- a/scripts/usage_history.sh
+++ b/scripts/usage_history.sh
@@ -49,6 +49,11 @@ quota)
datetime(resets_at, 'unixepoch', 'localtime') AS resets
FROM quota_samples ORDER BY sampled_at DESC LIMIT ${1:-40};"
;;
+skills)
+ run "SELECT provider, key AS skill, COUNT(*) AS uses FROM activity_events
+ WHERE kind = 'skill' AND day >= date('now', 'localtime', '-${1:-30} days')
+ GROUP BY provider, key ORDER BY uses DESC, provider, key LIMIT 20;"
+ ;;
export)
OUT="${1:-usage-history.csv}"
sqlite3 -header -csv "$DB" "SELECT day, provider, model, tokens FROM daily_tokens ORDER BY day, provider, model;" >"$OUT"
@@ -58,7 +63,7 @@ sql)
run "${1:?usage: usage_history.sh sql \"SELECT ...\"}"
;;
*)
- printf "usage: %s {status|daily|models|weekly|monthly|quota|export|sql|path} [argument]\n" "$(basename "$0")" >&2
+ printf "usage: %s {status|daily|models|weekly|monthly|quota|skills|export|sql|path} [argument]\n" "$(basename "$0")" >&2
exit 64
;;
esac