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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ choco install yasb
| [Bluetooth](https://github.com/amnweb/yasb/wiki/(Widget)-Bluetooth) | Shows the current Bluetooth status and connected devices. |
| [Brightness](https://github.com/amnweb/yasb/wiki/(Widget)-Brightness) | Displays and change the current brightness level. |
| [Cava](https://github.com/amnweb/yasb/wiki/(Widget)-Cava) | Displays audio visualizer using Cava. |
| [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. |
| [Copilot](https://github.com/amnweb/yasb/wiki/(Widget)-Copilot) | GitHub Copilot usage with a detailed menu showing statistics |
| [CPU](https://github.com/amnweb/yasb/wiki/(Widget)-CPU) | Shows the current CPU usage and information. |
| [Clock](https://github.com/amnweb/yasb/wiki/(Widget)-Clock) | Displays the current time and date, with customizable formats. |
Expand Down
136 changes: 129 additions & 7 deletions docs/widgets/(Widget)-Claude-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,35 @@ extra configuration is required as long as you are signed in to Claude Code.
| `label_alt` | string | `'Claude {seven_day}%'` | The alternative format string, toggled by the `toggle_label` callback. |
| `update_interval` | integer | `60` | How often the label and reset countdown are refreshed, in seconds. Must be between 30 and 3600. |
| `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. |
| `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`). |
| `seven_day_reset_format` | string | `'absolute'` | How the 7-day window's reset line is phrased in the popup: `relative` or `absolute`. |
| `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. |
| `token_history` | dict | `{'enabled': false, ...}` | Optional local token-usage history. See [Token history](#token-history). |
| `status` | dict | `{'enabled': false, ...}` | Optional Claude API status indicator. See [API status](#api-status). |
| `tooltip` | boolean | `true` | Whether to show a summary tooltip on hover. |
| `callbacks` | dict | `{'on_left': 'toggle_menu', 'on_middle': 'do_nothing', 'on_right': 'toggle_label'}` | Mouse-click callbacks. |
| `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. |
| `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. |

## Placeholders

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

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

```yaml
claude_usage:
Expand All @@ -40,7 +54,7 @@ claude_usage:
cache_ttl: 120
callbacks:
on_left: "toggle_menu" # open the usage menu
on_middle: "do_nothing"
on_middle: "refresh" # force an immediate re-fetch, bypassing cache_ttl
on_right: "toggle_label" # switch the bar text between 5h and 7d
menu:
blur: true
Expand All @@ -59,8 +73,10 @@ claude_usage:
- **label_alt:** The alternative format string, toggled with the `toggle_label` callback.
- **update_interval:** How often the bar label and reset countdown are refreshed, in seconds (30–3600).
- **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.
- **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.
- **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.
- **tooltip:** Whether to show a summary tooltip on hover.
- **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`.
- **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`.
- **menu:** A dictionary specifying the popup menu settings:
- **blur:** Enable blur effect for the menu.
- **round_corners:** Enable round corners (not supported on Windows 10).
Expand All @@ -69,6 +85,7 @@ claude_usage:
- **alignment:** Horizontal alignment of the menu (`left`, `right`, `center`).
- **direction:** Whether the menu opens `down` or `up`.
- **offset_top / offset_left:** Pixel offsets for fine positioning.
- **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.

## Authentication

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

Only Claude Code itself renews the OAuth token. If it has expired (e.g. you have not used
Claude Code in a while), the usage endpoint rejects the request and the widget keeps serving
the last cached values; the `{stale}` placeholder shows a warning glyph until the token is
refreshed by running any Claude Code command. The `refresh` action forces a re-fetch but
cannot renew an expired token.

## Refresh

The popup header has a refresh button that forces an immediate re-fetch, bypassing `cache_ttl`.
The same action is available as the `refresh` callback for any mouse button. While the menu is
open, its sections redraw in place when fresh data arrives. A refresh is ignored while a fetch
is already in flight.

## Token history

When `token_history.enabled` is `true`, the popup gains a **Tokens** section with a
Session / Today / Week / Month / Year toggle, the selected period's total, and an optional
usage graph. The same totals are available on the bar via the `{*_tokens}` placeholders.

The data comes from Claude Code's own session transcripts (`~/.claude/projects/**/*.jsonl`):
no API key and no network. Only numeric token counts, timestamps, the model name and the
session id are read; message content is never touched. The scan is incremental (a file is
re-parsed only when its size or mtime changes) and runs off the UI thread.

```yaml
token_history:
enabled: true
default_period: "today" # session | today | week | month | year
show_graph: true
show_graph_grid: false
week_starts_on: "monday" # monday | sunday
count_cache_read: true # false counts only new input/output/cache-creation
scan_interval: 120 # seconds between transcript scans (30–3600)
```

- **enabled:** Turn the Tokens section and `{*_tokens}` placeholders on.
- **default_period:** Which period is selected when the menu first opens.
- **show_graph / show_graph_grid:** Show a usage graph for the selected period, with an optional grid.
- **show_models:** Show a per-model token breakdown in the Tokens section, following the selected period. Top 5 models, computed from local transcripts.
- **week_starts_on:** First day of the week for the Week total.
- **count_cache_read:** Whether cache-read tokens count toward the totals. They dominate for heavy users; set `false` for "new work only".
- **scan_interval:** Seconds between transcript scans (30–3600).

> Session is the most recently active session's whole lifetime, so it can span days and may exceed Today.

## API status

When `status.enabled` is `true`, the widget can show a coloured dot reflecting the public
Claude API status (`status.claude.com`, no authentication). Use the `{status}` placeholder on
the bar, and/or an optional status line in the popup header (`show_in_menu`).

```yaml
status:
enabled: true
show_in_menu: true
icon: "●" # any glyph; coloured by .status.<level>
poll_interval: 300 # seconds between status checks (60–3600)
```

- **enabled:** Turn the `{status}`/`{status_text}` placeholders and the menu status line on.
- **show_in_menu:** Show a dot + description line in the popup header.
- **icon:** The glyph used for the dot. Its colour comes from the `.status.<level>` class.
- **poll_interval:** Seconds between status checks (60–3600).

## Widget Style
```css
.claude-usage {}
.claude-usage .widget-container {}
.claude-usage .icon {}
.claude-usage .label {}
.claude-usage .stale {} /* warning glyph while the OAuth token is expired */
.claude-usage .status {} /* {status} dot on the bar */
.claude-usage .status.none {} /* green / minor / major / critical / unknown */
.claude-usage .status.minor {}
.claude-usage .status.major {}
.claude-usage .status.critical {}
/* Popup menu */
.claude-usage-menu {}
.claude-usage-menu .header {} /* "Claude Usage" title */
.claude-usage-menu .header {} /* header row (title + refresh button) */
.claude-usage-menu .header .text {} /* "Claude Usage" title */
.claude-usage-menu .header .refresh {} /* refresh button */
.claude-usage-menu .header .refresh:hover {}
.claude-usage-menu .status-row {} /* status line below the header (show_in_menu) */
.claude-usage-menu .status-row .dot {} /* coloured via .dot.<level> */
.claude-usage-menu .status-row .status-text {}
.claude-usage-menu .section {}
.claude-usage-menu .section .title {}
.claude-usage-menu .section .progress {} /* progress-bar track */
Expand All @@ -99,6 +192,25 @@ signed in to Claude Code, the widget shows `--` until you sign in.
.claude-usage-menu .section .footer .percent.medium {}
.claude-usage-menu .section .footer .percent.high {}
.claude-usage-menu .section .date {} /* absolute reset timestamp */
/* Token history section (token_history.enabled) */
.claude-usage-menu .section.tokens {}
.claude-usage-menu .section .period-toggle {}
.claude-usage-menu .section .period-btn {}
.claude-usage-menu .section .period-btn.active {}
.claude-usage-menu .section .token-total {}
.claude-usage-menu .section.tokens .model-usage {} /* per-model breakdown container */
.claude-usage-menu .section.tokens .model-usage .title {} /* "Models" header */
.claude-usage-menu .section.tokens .model-rows {} /* the per-model bar rows */
.claude-usage-menu .section.tokens .model-name {}
.claude-usage-menu .section.tokens .model-total {}
.claude-usage-menu .section.tokens .model-rows .progress.model-0 .fill {} /* bar accent 0..4 */
.claude-usage-menu .section.tokens .model-rows .progress.model-1 .fill {}
.claude-usage-menu .section.tokens .model-rows .progress.model-2 .fill {}
.claude-usage-menu .section.tokens .model-rows .progress.model-3 .fill {}
.claude-usage-menu .section.tokens .model-rows .progress.model-4 .fill {}
.claude-usage-menu .header .pin-btn {} /* pin button (use font-family "Segoe Fluent Icons" for the glyphs) */
.claude-usage-menu .header .pin-btn.pinned {} /* while pinned */
.claude-usage-menu .section .graph-container {}
```

## Example Style
Expand All @@ -117,10 +229,20 @@ signed in to Claude Code, the widget shows `--` until you sign in.
min-width: 260px;
}
.claude-usage-menu .header {
padding: 14px 16px 10px 16px;
}
.claude-usage-menu .header .text {
color: #cdd6f4;
font-size: 15px;
font-weight: bold;
padding: 14px 16px 10px 16px;
}
.claude-usage-menu .header .refresh {
color: #6c7086;
font-size: 15px;
padding: 0 2px;
}
.claude-usage-menu .header .refresh:hover {
color: #fab387;
}
.claude-usage-menu .section {
padding: 4px 16px 12px 16px;
Expand Down
46 changes: 30 additions & 16 deletions src/core/utils/stat_popup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from PyQt6.QtCore import QEvent, QPointF, Qt
from PyQt6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget

from core.utils.tooltip import set_tooltip
from core.utils.utilities import PopupWidget, refresh_widget_style
Expand Down Expand Up @@ -60,6 +60,34 @@ def closeEvent(self, event):
return
super().closeEvent(event)

def resizeEvent(self, event):
if self._is_pinned:
# A pinned popup keeps the position the user dragged it to. PopupWidget.resizeEvent
# re-anchors to the bar on every resize, which is what snaps a pinned popup back on a
# refresh; resize the content to fill the new size but skip the re-anchor.
self._popup_content.setGeometry(0, 0, self.width(), self.height())
QWidget.resizeEvent(self, event)
return
super().resizeEvent(event)


def create_pin_button(popup: PopupWidget, pin_icon: str, unpin_icon: str) -> QPushButton:
"""A checkable header button that keeps ``popup`` open and draggable while checked."""
pin_btn = QPushButton(pin_icon)
pin_btn.setCheckable(True)
pin_btn.setProperty("class", "pin-btn")
set_tooltip(pin_btn, "Pin this window")

def on_toggled(checked: bool):
pin_btn.setText(unpin_icon if checked else pin_icon)
pin_btn.setProperty("class", "pin-btn pinned" if checked else "pin-btn")
set_tooltip(pin_btn, "Unpin this window" if checked else "Pin this window")
refresh_widget_style(pin_btn)
popup._is_pinned = checked

pin_btn.toggled.connect(on_toggled)
return pin_btn


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

pin_icon = menu_config.pin_icon
unpin_icon = menu_config.unpin_icon
pin_btn = QPushButton(pin_icon)
pin_btn.setCheckable(True)
pin_btn.setProperty("class", "pin-btn")
set_tooltip(pin_btn, "Pin this window")

def on_pin_toggled(checked: bool):
pin_btn.setText(unpin_icon if checked else pin_icon)
pin_btn.setProperty("class", "pin-btn pinned" if checked else "pin-btn")
set_tooltip(pin_btn, "Pin this window" if not checked else "Unpin this window")
refresh_widget_style(pin_btn)
popup._is_pinned = checked

pin_btn.toggled.connect(on_pin_toggled)
pin_btn = create_pin_button(popup, menu_config.pin_icon, menu_config.unpin_icon)
header_layout.addWidget(pin_btn)
layout.addWidget(header)

Expand Down
29 changes: 29 additions & 0 deletions src/core/validation/widgets/yasb/claude_usage.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from pydantic import Field

from core.validation.widgets.base_model import (
Expand All @@ -21,13 +23,40 @@ class ClaudeUsageMenuConfig(CustomBaseModel):
direction: str = "down"
offset_top: int = 6
offset_left: int = 0
pin_icon: str = "\ue718"
unpin_icon: str = "\ue77a"


class ClaudeTokenHistoryConfig(CustomBaseModel):
enabled: bool = False
default_period: Literal["session", "today", "week", "month", "year"] = "today"
show_graph: bool = False
show_graph_grid: bool = False
show_models: bool = False
week_starts_on: Literal["monday", "sunday"] = "monday"
# Cache-read tokens dominate the totals for heavy users; set false for "new work only".
count_cache_read: bool = True
scan_interval: int = Field(default=120, ge=30, le=3600)


class ClaudeStatusConfig(CustomBaseModel):
enabled: bool = False
show_in_menu: bool = True
icon: str = "●" # coloured via .status.<level> CSS classes
poll_interval: int = Field(default=300, ge=60, le=3600)


class ClaudeUsageConfig(CustomBaseModel):
label: str = "Claude {five_hour}%"
label_alt: str = "Claude {seven_day}%"
update_interval: int = Field(default=60, ge=30, le=3600)
cache_ttl: int = Field(default=120, ge=0, le=3600)
token_history: ClaudeTokenHistoryConfig = ClaudeTokenHistoryConfig()
status: ClaudeStatusConfig = ClaudeStatusConfig()
# Popup reset line per window: "relative" -> "Resets in 4h 11m", "absolute" -> "Resets on Sat @ 6:00 AM".
five_hour_reset_format: Literal["relative", "absolute"] = "relative"
seven_day_reset_format: Literal["relative", "absolute"] = "absolute"
reset_show_date: bool = True
tooltip: bool = True
callbacks: ClaudeUsageCallbacksConfig = ClaudeUsageCallbacksConfig()
menu: ClaudeUsageMenuConfig = ClaudeUsageMenuConfig()
Expand Down
Loading