Skip to content

Commit 9403117

Browse files
feat(claude_usage): token history, API status, model breakdown and pinnable popup
Builds on the existing claude_usage widget; everything is off by default and closes #967, #970 and #971. - refresh action plus a header button that force an immediate fetch and skip cache_ttl, with in-flight dedup; a {stale} indicator that shows once the OAuth token has expired; and per-window reset wording (relative or absolute, with an optional date) that fixes the broken "Resets in Sat 6:00 AM" line - local token-usage history: a Session/Today/Week/Month/Year section read from Claude Code's own transcripts under ~/.claude/projects, scanned off-thread and incrementally, with the cache bounded (hourly past 15 days and daily past ~13 months are pruned on scan). No API key, no network call, content never read - Claude API status dot driven by the public status.claude.com page (no auth), keeping the last known status on a failed poll - per-model token breakdown in the Tokens section, following the same period toggle. Model names are derived from the id so new models need no upkeep, and fast-mode usage (usage.speed) is split into its own row - pinnable, draggable popup matching the cpu/memory/gpu widgets, via a shared create_pin_button helper in stat_popup - the popup resizes to fit the selected period instead of cramming or stretching - docs and example CSS under docs/ Co-Authored-By: ManaphatDev <106569727+Gaer12TH@users.noreply.github.com>
1 parent 185a25c commit 9403117

8 files changed

Lines changed: 1289 additions & 54 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ choco install yasb
9090
| [Bluetooth](https://github.com/amnweb/yasb/wiki/(Widget)-Bluetooth) | Shows the current Bluetooth status and connected devices. |
9191
| [Brightness](https://github.com/amnweb/yasb/wiki/(Widget)-Brightness) | Displays and change the current brightness level. |
9292
| [Cava](https://github.com/amnweb/yasb/wiki/(Widget)-Cava) | Displays audio visualizer using Cava. |
93+
| [Claude Usage](https://github.com/amnweb/yasb/wiki/(Widget)-Claude-Usage) | Shows your Claude (Claude Code) subscription usage with a popup of the 5-hour and 7-day limits. |
9394
| [Copilot](https://github.com/amnweb/yasb/wiki/(Widget)-Copilot) | GitHub Copilot usage with a detailed menu showing statistics |
9495
| [CPU](https://github.com/amnweb/yasb/wiki/(Widget)-CPU) | Shows the current CPU usage and information. |
9596
| [Clock](https://github.com/amnweb/yasb/wiki/(Widget)-Clock) | Displays the current time and date, with customizable formats. |

docs/widgets/(Widget)-Claude-Usage.md

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,35 @@ extra configuration is required as long as you are signed in to Claude Code.
1414
| `label_alt` | string | `'Claude {seven_day}%'` | The alternative format string, toggled by the `toggle_label` callback. |
1515
| `update_interval` | integer | `60` | How often the label and reset countdown are refreshed, in seconds. Must be between 30 and 3600. |
1616
| `cache_ttl` | integer | `120` | How long (seconds) a fetched result is cached on disk before the endpoint is queried again. The endpoint is rate-limited, so keep this at a sane value. |
17+
| `five_hour_reset_format` | string | `'relative'` | How the 5-hour window's reset line is phrased in the popup: `relative` (`Resets in 4h 11m`) or `absolute` (`Resets on Sat @ 6:00 AM`). |
18+
| `seven_day_reset_format` | string | `'absolute'` | How the 7-day window's reset line is phrased in the popup: `relative` or `absolute`. |
19+
| `reset_show_date` | boolean | `true` | In `absolute` mode, include the month/day (`Resets on Sat, Jun 13 @ 6:00 AM`) so two windows resetting on the same weekday stay distinguishable. |
20+
| `token_history` | dict | `{'enabled': false, ...}` | Optional local token-usage history. See [Token history](#token-history). |
21+
| `status` | dict | `{'enabled': false, ...}` | Optional Claude API status indicator. See [API status](#api-status). |
1722
| `tooltip` | boolean | `true` | Whether to show a summary tooltip on hover. |
1823
| `callbacks` | dict | `{'on_left': 'toggle_menu', 'on_middle': 'do_nothing', 'on_right': 'toggle_label'}` | Mouse-click callbacks. |
19-
| `menu` | dict | `{'blur': true, 'round_corners': true, 'round_corners_type': 'normal', 'border_color': 'System', 'alignment': 'right', 'direction': 'down', 'offset_top': 6, 'offset_left': 0}` | Popup menu settings. |
24+
| `menu` | dict | `{'blur': true, 'round_corners': true, 'round_corners_type': 'normal', 'border_color': 'System', 'alignment': 'right', 'direction': 'down', 'offset_top': 6, 'offset_left': 0, 'pin_icon': '', 'unpin_icon': ''}` | Popup menu settings. |
2025

2126
## Placeholders
2227

2328
The label is plain text by default. You can prepend a Nerd Font glyph in a `<span>` if you
24-
want an icon (e.g. `<span>\U000f06a9</span> {five_hour}%`). The following placeholders can be
25-
used in `label` / `label_alt`:
29+
want an icon (e.g. `<span>\U000f06a9</span> {five_hour}%`), or embed your own image with an
30+
`<img>` tag (e.g. `<img src='C:/path/to/claude.svg' width='14' height='14'> {five_hour}%`). The
31+
following placeholders can be used in `label` / `label_alt`:
2632

2733
- `{five_hour}` — 5-hour window utilization (percent, `--` when unavailable).
2834
- `{seven_day}` — 7-day window utilization (percent, `--` when unavailable).
2935
- `{five_hour_reset}` — time until the 5-hour window resets. Shown as a countdown when under a
3036
day away (e.g. `4h 27m`), otherwise as a local weekday + time (e.g. `Sat 6:00 AM`).
3137
- `{seven_day_reset}` — time until the 7-day window resets (e.g. `Sat 6:00 AM`).
38+
- `{stale}` — a warning glyph shown only while Claude Code's OAuth token has expired, empty
39+
otherwise. Place it in its own `<span>` (e.g. `{five_hour}% <span class='stale'>{stale}</span>`).
40+
- `{session_tokens}` `{today_tokens}` `{week_tokens}` `{month_tokens}` `{year_tokens}` — compact
41+
token totals (e.g. `1.2M`) for each period. Require `token_history.enabled`; `--` otherwise.
42+
- `{status}` — a status dot, coloured by the current Claude API status level via
43+
`.status.<none|minor|major|critical>` classes. Place it in its own `<span>`. Requires
44+
`status.enabled`; empty otherwise.
45+
- `{status_text}` — the status description (e.g. `All Systems Operational`).
3246

3347
```yaml
3448
claude_usage:
@@ -40,7 +54,7 @@ claude_usage:
4054
cache_ttl: 120
4155
callbacks:
4256
on_left: "toggle_menu" # open the usage menu
43-
on_middle: "do_nothing"
57+
on_middle: "refresh" # force an immediate re-fetch, bypassing cache_ttl
4458
on_right: "toggle_label" # switch the bar text between 5h and 7d
4559
menu:
4660
blur: true
@@ -59,8 +73,10 @@ claude_usage:
5973
- **label_alt:** The alternative format string, toggled with the `toggle_label` callback.
6074
- **update_interval:** How often the bar label and reset countdown are refreshed, in seconds (30–3600).
6175
- **cache_ttl:** How long a fetched result is cached on disk before the usage endpoint is queried again. Because the endpoint is rate-limited (HTTP 429), the widget serves the last cached value on any error instead of going blank.
76+
- **five_hour_reset_format / seven_day_reset_format:** How each window's reset line is phrased in the popup. `relative` shows a countdown (`Resets in 4h 11m`); `absolute` shows a local weekday and time (`Resets on Sat @ 6:00 AM`). The exact reset timestamp is always shown on the line below.
77+
- **reset_show_date:** In `absolute` mode, include the month/day in the reset line so the 5-hour and 7-day windows can be told apart when they fall on the same weekday. No effect in `relative` mode.
6278
- **tooltip:** Whether to show a summary tooltip on hover.
63-
- **callbacks:** Mouse-click callbacks. Built-in actions: `toggle_menu` (open/close the popup menu), `toggle_label` (swap between `label` and `label_alt`), `do_nothing`, and `exec`.
79+
- **callbacks:** Mouse-click callbacks. Built-in actions: `toggle_menu` (open/close the popup menu), `toggle_label` (swap between `label` and `label_alt`), `refresh` (force an immediate re-fetch, bypassing `cache_ttl`), `do_nothing`, and `exec`.
6480
- **menu:** A dictionary specifying the popup menu settings:
6581
- **blur:** Enable blur effect for the menu.
6682
- **round_corners:** Enable round corners (not supported on Windows 10).
@@ -69,6 +85,7 @@ claude_usage:
6985
- **alignment:** Horizontal alignment of the menu (`left`, `right`, `center`).
7086
- **direction:** Whether the menu opens `down` or `up`.
7187
- **offset_top / offset_left:** Pixel offsets for fine positioning.
88+
- **pin_icon / unpin_icon:** Nerd Font glyphs for the pin button in the popup header. The button keeps the popup open and lets it be dragged when pinned.
7289

7390
## Authentication
7491

@@ -77,15 +94,91 @@ The widget reuses Claude Code's existing OAuth session. It reads the access toke
7794
that environment variable is set) and never logs or stores it elsewhere. If you are not
7895
signed in to Claude Code, the widget shows `--` until you sign in.
7996

97+
Only Claude Code itself renews the OAuth token. If it has expired (e.g. you have not used
98+
Claude Code in a while), the usage endpoint rejects the request and the widget keeps serving
99+
the last cached values; the `{stale}` placeholder shows a warning glyph until the token is
100+
refreshed by running any Claude Code command. The `refresh` action forces a re-fetch but
101+
cannot renew an expired token.
102+
103+
## Refresh
104+
105+
The popup header has a refresh button that forces an immediate re-fetch, bypassing `cache_ttl`.
106+
The same action is available as the `refresh` callback for any mouse button. While the menu is
107+
open, its sections redraw in place when fresh data arrives. A refresh is ignored while a fetch
108+
is already in flight.
109+
110+
## Token history
111+
112+
When `token_history.enabled` is `true`, the popup gains a **Tokens** section with a
113+
Session / Today / Week / Month / Year toggle, the selected period's total, and an optional
114+
usage graph. The same totals are available on the bar via the `{*_tokens}` placeholders.
115+
116+
The data comes from Claude Code's own session transcripts (`~/.claude/projects/**/*.jsonl`):
117+
no API key and no network. Only numeric token counts, timestamps, the model name and the
118+
session id are read; message content is never touched. The scan is incremental (a file is
119+
re-parsed only when its size or mtime changes) and runs off the UI thread.
120+
121+
```yaml
122+
token_history:
123+
enabled: true
124+
default_period: "today" # session | today | week | month | year
125+
show_graph: true
126+
show_graph_grid: false
127+
week_starts_on: "monday" # monday | sunday
128+
count_cache_read: true # false counts only new input/output/cache-creation
129+
scan_interval: 120 # seconds between transcript scans (30–3600)
130+
```
131+
132+
- **enabled:** Turn the Tokens section and `{*_tokens}` placeholders on.
133+
- **default_period:** Which period is selected when the menu first opens.
134+
- **show_graph / show_graph_grid:** Show a usage graph for the selected period, with an optional grid.
135+
- **show_models:** Show a per-model token breakdown in the Tokens section, following the selected period. Top 5 models, computed from local transcripts.
136+
- **week_starts_on:** First day of the week for the Week total.
137+
- **count_cache_read:** Whether cache-read tokens count toward the totals. They dominate for heavy users; set `false` for "new work only".
138+
- **scan_interval:** Seconds between transcript scans (30–3600).
139+
140+
> Session is the most recently active session's whole lifetime, so it can span days and may exceed Today.
141+
142+
## API status
143+
144+
When `status.enabled` is `true`, the widget can show a coloured dot reflecting the public
145+
Claude API status (`status.claude.com`, no authentication). Use the `{status}` placeholder on
146+
the bar, and/or an optional status line in the popup header (`show_in_menu`).
147+
148+
```yaml
149+
status:
150+
enabled: true
151+
show_in_menu: true
152+
icon: "●" # any glyph; coloured by .status.<level>
153+
poll_interval: 300 # seconds between status checks (60–3600)
154+
```
155+
156+
- **enabled:** Turn the `{status}`/`{status_text}` placeholders and the menu status line on.
157+
- **show_in_menu:** Show a dot + description line in the popup header.
158+
- **icon:** The glyph used for the dot. Its colour comes from the `.status.<level>` class.
159+
- **poll_interval:** Seconds between status checks (60–3600).
160+
80161
## Widget Style
81162
```css
82163
.claude-usage {}
83164
.claude-usage .widget-container {}
84165
.claude-usage .icon {}
85166
.claude-usage .label {}
167+
.claude-usage .stale {} /* warning glyph while the OAuth token is expired */
168+
.claude-usage .status {} /* {status} dot on the bar */
169+
.claude-usage .status.none {} /* green / minor / major / critical / unknown */
170+
.claude-usage .status.minor {}
171+
.claude-usage .status.major {}
172+
.claude-usage .status.critical {}
86173
/* Popup menu */
87174
.claude-usage-menu {}
88-
.claude-usage-menu .header {} /* "Claude Usage" title */
175+
.claude-usage-menu .header {} /* header row (title + refresh button) */
176+
.claude-usage-menu .header .text {} /* "Claude Usage" title */
177+
.claude-usage-menu .header .refresh {} /* refresh button */
178+
.claude-usage-menu .header .refresh:hover {}
179+
.claude-usage-menu .status-row {} /* status line below the header (show_in_menu) */
180+
.claude-usage-menu .status-row .dot {} /* coloured via .dot.<level> */
181+
.claude-usage-menu .status-row .status-text {}
89182
.claude-usage-menu .section {}
90183
.claude-usage-menu .section .title {}
91184
.claude-usage-menu .section .progress {} /* progress-bar track */
@@ -99,6 +192,26 @@ signed in to Claude Code, the widget shows `--` until you sign in.
99192
.claude-usage-menu .section .footer .percent.medium {}
100193
.claude-usage-menu .section .footer .percent.high {}
101194
.claude-usage-menu .section .date {} /* absolute reset timestamp */
195+
/* Token history section (token_history.enabled) */
196+
.claude-usage-menu .section.tokens {}
197+
.claude-usage-menu .section .period-toggle {}
198+
.claude-usage-menu .section .period-btn {}
199+
.claude-usage-menu .section .period-btn.active {}
200+
.claude-usage-menu .section .token-total {}
201+
.claude-usage-menu .section.tokens .model-usage {} /* per-model breakdown container */
202+
.claude-usage-menu .section.tokens .model-usage .title {} /* "Models" header */
203+
.claude-usage-menu .section.tokens .model-rows {} /* the per-model bar rows */
204+
.claude-usage-menu .section.tokens .model-row {} /* one model: name | bar | total */
205+
.claude-usage-menu .section.tokens .model-name {}
206+
.claude-usage-menu .section.tokens .model-total {}
207+
.claude-usage-menu .section.tokens .model-row .progress.model-0 .fill {} /* bar accent 0..4 */
208+
.claude-usage-menu .section.tokens .model-row .progress.model-1 .fill {}
209+
.claude-usage-menu .section.tokens .model-row .progress.model-2 .fill {}
210+
.claude-usage-menu .section.tokens .model-row .progress.model-3 .fill {}
211+
.claude-usage-menu .section.tokens .model-row .progress.model-4 .fill {}
212+
.claude-usage-menu .header .pin-btn {} /* pin button (use font-family "Segoe Fluent Icons" for the glyphs) */
213+
.claude-usage-menu .header .pin-btn.pinned {} /* while pinned */
214+
.claude-usage-menu .section .graph-container {}
102215
```
103216

104217
## Example Style
@@ -117,10 +230,20 @@ signed in to Claude Code, the widget shows `--` until you sign in.
117230
min-width: 260px;
118231
}
119232
.claude-usage-menu .header {
233+
padding: 14px 16px 10px 16px;
234+
}
235+
.claude-usage-menu .header .text {
120236
color: #cdd6f4;
121237
font-size: 15px;
122238
font-weight: bold;
123-
padding: 14px 16px 10px 16px;
239+
}
240+
.claude-usage-menu .header .refresh {
241+
color: #6c7086;
242+
font-size: 15px;
243+
padding: 0 2px;
244+
}
245+
.claude-usage-menu .header .refresh:hover {
246+
color: #fab387;
124247
}
125248
.claude-usage-menu .section {
126249
padding: 4px 16px 12px 16px;

src/core/utils/stat_popup.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from PyQt6.QtCore import QEvent, QPointF, Qt
22
from PyQt6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
3-
from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
3+
from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
44

55
from core.utils.tooltip import set_tooltip
66
from core.utils.utilities import PopupWidget, refresh_widget_style
@@ -60,6 +60,34 @@ def closeEvent(self, event):
6060
return
6161
super().closeEvent(event)
6262

63+
def resizeEvent(self, event):
64+
if self._is_pinned:
65+
# A pinned popup keeps the position the user dragged it to. PopupWidget.resizeEvent
66+
# re-anchors to the bar on every resize, which is what snaps a pinned popup back on a
67+
# refresh; resize the content to fill the new size but skip the re-anchor.
68+
self._popup_content.setGeometry(0, 0, self.width(), self.height())
69+
QWidget.resizeEvent(self, event)
70+
return
71+
super().resizeEvent(event)
72+
73+
74+
def create_pin_button(popup: PopupWidget, pin_icon: str, unpin_icon: str) -> QPushButton:
75+
"""A checkable header button that keeps ``popup`` open and draggable while checked."""
76+
pin_btn = QPushButton(pin_icon)
77+
pin_btn.setCheckable(True)
78+
pin_btn.setProperty("class", "pin-btn")
79+
set_tooltip(pin_btn, "Pin this window")
80+
81+
def on_toggled(checked: bool):
82+
pin_btn.setText(unpin_icon if checked else pin_icon)
83+
pin_btn.setProperty("class", "pin-btn pinned" if checked else "pin-btn")
84+
set_tooltip(pin_btn, "Unpin this window" if checked else "Pin this window")
85+
refresh_widget_style(pin_btn)
86+
popup._is_pinned = checked
87+
88+
pin_btn.toggled.connect(on_toggled)
89+
return pin_btn
90+
6391

6492
class GraphWidget(QFrame):
6593
"""Rolling area chart for percentage-based history data (0-100)."""
@@ -236,21 +264,7 @@ def build_stat_popup(
236264
header_layout.addWidget(title_label)
237265
header_layout.addStretch()
238266

239-
pin_icon = menu_config.pin_icon
240-
unpin_icon = menu_config.unpin_icon
241-
pin_btn = QPushButton(pin_icon)
242-
pin_btn.setCheckable(True)
243-
pin_btn.setProperty("class", "pin-btn")
244-
set_tooltip(pin_btn, "Pin this window")
245-
246-
def on_pin_toggled(checked: bool):
247-
pin_btn.setText(unpin_icon if checked else pin_icon)
248-
pin_btn.setProperty("class", "pin-btn pinned" if checked else "pin-btn")
249-
set_tooltip(pin_btn, "Pin this window" if not checked else "Unpin this window")
250-
refresh_widget_style(pin_btn)
251-
popup._is_pinned = checked
252-
253-
pin_btn.toggled.connect(on_pin_toggled)
267+
pin_btn = create_pin_button(popup, menu_config.pin_icon, menu_config.unpin_icon)
254268
header_layout.addWidget(pin_btn)
255269
layout.addWidget(header)
256270

src/core/validation/widgets/yasb/claude_usage.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Literal
2+
13
from pydantic import Field
24

35
from core.validation.widgets.base_model import (
@@ -21,13 +23,40 @@ class ClaudeUsageMenuConfig(CustomBaseModel):
2123
direction: str = "down"
2224
offset_top: int = 6
2325
offset_left: int = 0
26+
pin_icon: str = "\ue718"
27+
unpin_icon: str = "\ue77a"
28+
29+
30+
class ClaudeTokenHistoryConfig(CustomBaseModel):
31+
enabled: bool = False
32+
default_period: Literal["session", "today", "week", "month", "year"] = "today"
33+
show_graph: bool = False
34+
show_graph_grid: bool = False
35+
show_models: bool = False
36+
week_starts_on: Literal["monday", "sunday"] = "monday"
37+
# Cache-read tokens dominate the totals for heavy users; set false for "new work only".
38+
count_cache_read: bool = True
39+
scan_interval: int = Field(default=120, ge=30, le=3600)
40+
41+
42+
class ClaudeStatusConfig(CustomBaseModel):
43+
enabled: bool = False
44+
show_in_menu: bool = True
45+
icon: str = "●" # coloured via .status.<level> CSS classes
46+
poll_interval: int = Field(default=300, ge=60, le=3600)
2447

2548

2649
class ClaudeUsageConfig(CustomBaseModel):
2750
label: str = "Claude {five_hour}%"
2851
label_alt: str = "Claude {seven_day}%"
2952
update_interval: int = Field(default=60, ge=30, le=3600)
3053
cache_ttl: int = Field(default=120, ge=0, le=3600)
54+
token_history: ClaudeTokenHistoryConfig = ClaudeTokenHistoryConfig()
55+
status: ClaudeStatusConfig = ClaudeStatusConfig()
56+
# Popup reset line per window: "relative" -> "Resets in 4h 11m", "absolute" -> "Resets on Sat @ 6:00 AM".
57+
five_hour_reset_format: Literal["relative", "absolute"] = "relative"
58+
seven_day_reset_format: Literal["relative", "absolute"] = "absolute"
59+
reset_show_date: bool = True
3160
tooltip: bool = True
3261
callbacks: ClaudeUsageCallbacksConfig = ClaudeUsageCallbacksConfig()
3362
menu: ClaudeUsageMenuConfig = ClaudeUsageMenuConfig()

0 commit comments

Comments
 (0)