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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ python cli.py week # last 7 days, per-day + by-model
python cli.py stats # all-time stats
python cli.py dashboard # scan + open http://localhost:8080
python cli.py dashboard --host 0.0.0.0 --port 9000
python cli.py dashboard --auto-refresh 120 # rescan every 120s (default 30)
python cli.py dashboard --no-auto-refresh # scan once at startup only
python cli.py scan --projects-dir PATH # scan a custom transcripts dir
# or via env vars:
HOST=0.0.0.0 PORT=9000 python cli.py dashboard
AUTO_REFRESH=120 python cli.py dashboard

python -m unittest discover -s tests -v # full test suite (CI runs this)
python -m unittest tests.test_scanner -v # one file
Expand Down Expand Up @@ -81,7 +84,7 @@ Pricing is duplicated in two places that **must stay in sync**:
### Dashboard server

`http.server.BaseHTTPRequestHandler`-based, two endpoints:
- `GET /api/data` → JSON snapshot from `get_dashboard_data()`. Returns *all* history; client-side filters by date range and model.
- `GET /api/data` → JSON snapshot from `get_dashboard_data()`. Returns *all* history; client-side filters by date range and model. **It only reads the DB — it never scans.** Freshness comes from the rescan loop in `cli.cmd_dashboard`'s background thread (`--auto-refresh`, default 30s), which is what makes the browser's 30s `/api/data` poll actually show new data. Before that loop existed, a dashboard left open served the startup scan's snapshot forever while the footer cheerfully advertised "Auto-refresh in 30s". If you refactor the scan scheduling, keep *something* writing to the DB while the server runs.
- `POST /api/rescan` → deletes the DB and runs a full rescan. Passes `db_path` and `projects_dirs` explicitly so tests that monkey-patch the module globals work — scan's default arg values are frozen at def time, so don't switch to bare defaults.

The entire UI lives in `HTML_TEMPLATE` as a raw string. Chart.js is loaded from CDN.
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## v1.5.6 — TBD

### Dashboard

- Fixed the dashboard serving a **frozen snapshot** when left open: the browser polls `/api/data` every 30s and the footer advertises "Auto-refresh in 30s", but nothing ever rescanned the transcripts after the startup scan, so the numbers stopped moving the moment that scan finished. `cli.py dashboard` now runs an incremental rescan on the same 30s cadence, so today's usage keeps filling in without a restart.
- Added `--auto-refresh SECONDS` (and the `AUTO_REFRESH` environment variable) to tune the rescan interval, plus `--no-auto-refresh` / `--auto-refresh 0` to restore the scan-once-at-startup behaviour. Rescans are incremental — `processed_files` skips any transcript whose mtime is unchanged — so a steady-state pass over a ~700-file backlog touches only the live sessions.

## v1.5.5 — 2026-07-10

### Dashboard
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ python cli.py dashboard --host 0.0.0.0 --port 9000
# Environment variables are also supported
HOST=0.0.0.0 PORT=9000 python cli.py dashboard

# Rescan every 2 minutes instead of the default 30 seconds
python cli.py dashboard --auto-refresh 120

# Only scan once at startup (the pre-1.5.6 behaviour)
python cli.py dashboard --no-auto-refresh

# Scan a custom projects directory
python cli.py scan --projects-dir /path/to/transcripts
```
Expand All @@ -136,7 +142,7 @@ Claude Code writes one JSONL file per session to `~/.claude/projects/`. Each lin

`scanner.py` parses those files and stores the data in a SQLite database at `~/.claude/usage.db`.

`dashboard.py` serves a single-page dashboard on `localhost:8080` with Chart.js charts (loaded from CDN). It auto-refreshes every 30 seconds and supports model filtering and a date-range dropdown with bookmarkable URLs. A sticky section nav jumps between sections, and every chart/table can be collapsed (remembered across reloads). The bind address and port can be configured with the `--host` and `--port` flags, or the `HOST` and `PORT` environment variables (defaults: `localhost`, `8080`).
`dashboard.py` serves a single-page dashboard on `localhost:8080` with Chart.js charts (loaded from CDN). It auto-refreshes every 30 seconds — the browser re-polls `/api/data` while the server rescans your transcripts on the same cadence, so a dashboard left open keeps tracking live sessions. Use `--auto-refresh SECONDS` to change the rescan interval or `--no-auto-refresh` to scan only at startup. It also supports model filtering and a date-range dropdown with bookmarkable URLs. A sticky section nav jumps between sections, and every chart/table can be collapsed (remembered across reloads). The bind address and port can be configured with the `--host` and `--port` flags, or the `HOST` and `PORT` environment variables (defaults: `localhost`, `8080`).

---

Expand Down
61 changes: 58 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

DB_PATH = Path(os.environ.get("CLAUDE_USAGE_DB", Path.home() / ".claude" / "usage.db"))

# Seconds between the dashboard's background rescans. Matches the browser's own
# 30s /api/data poll, so a refresh lands on data at most one tick old.
DEFAULT_AUTO_REFRESH = 30

PRICING = {
# Fable / Mythos — Anthropic's most capable class, priced at 2x Opus.
# (Mythos 5 shares Fable 5's pricing; Project-Glasswing access only.)
Expand Down Expand Up @@ -98,9 +102,9 @@ def require_db():

# ── Commands ──────────────────────────────────────────────────────────────────

def cmd_scan(projects_dir=None):
def cmd_scan(projects_dir=None, verbose=True):
from scanner import scan
scan(projects_dir=Path(projects_dir) if projects_dir else None)
scan(projects_dir=Path(projects_dir) if projects_dir else None, verbose=verbose)


def cmd_today():
Expand Down Expand Up @@ -394,14 +398,17 @@ def cmd_stats():
conn.close()


def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None):
def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None,
auto_refresh=None):
import threading
import time

from dashboard import serve

host = host or os.environ.get("HOST", "localhost")
port = int(port or os.environ.get("PORT", "8080"))
if auto_refresh is None:
auto_refresh = int(os.environ.get("AUTO_REFRESH", DEFAULT_AUTO_REFRESH))

# Bind and serve the port *first*, then scan in the background. A cold scan
# over a large ~/.claude/projects backlog can take well over a minute, and
Expand All @@ -420,6 +427,27 @@ def background_scan():
scan(projects_dir=projects_dir)
print("Background scan complete.")

# Then keep rescanning, so a dashboard left open overnight doesn't serve a
# frozen snapshot. /api/data only *reads* the DB, and the browser polls it
# every 30s advertising "Auto-refresh in 30s" in the footer — without this
# loop nothing ever writes new turns, so that promise silently went stale
# the moment the startup scan finished.
#
# Rescans are incremental and cheap: processed_files skips any transcript
# whose mtime is unchanged, so a steady-state pass over a ~700-file backlog
# touches only the handful of live sessions (~0.1s). verbose=False keeps the
# terminal quiet — the startup scan already reported the backlog.
if not auto_refresh:
return
while True:
time.sleep(auto_refresh)
try:
scan(projects_dir=projects_dir, verbose=False)
except Exception as exc:
# Never let a transient failure (locked DB, transcript mid-write)
# kill the loop — the next tick usually succeeds.
print(f"Auto-refresh scan failed: {exc}")

threading.Thread(target=background_scan, daemon=True).start()

# Open a browser for users running this as a script (see README). The VS Code
Expand Down Expand Up @@ -447,7 +475,9 @@ def open_browser():
python cli.py week Show last 7 days (per-day + by-model)
python cli.py stats Show all-time statistics
python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] [--no-browser] [--surface SURFACE]
[--auto-refresh SECONDS | --no-auto-refresh]
Scan + start dashboard (opens a browser unless --no-browser)
Rescans every 30s by default; --no-auto-refresh scans only at startup
python cli.py --version Print the version and exit
"""

Expand All @@ -466,6 +496,30 @@ def parse_named_arg(args, flag):
return args[i + 1]
return None


def parse_auto_refresh(args):
"""Resolve --auto-refresh [SECONDS] / --no-auto-refresh into a seconds value.

Returns None when neither flag is given, so the caller can fall back to the
AUTO_REFRESH env var and then DEFAULT_AUTO_REFRESH. 0 disables the rescan loop.
"""
if "--no-auto-refresh" in args:
return 0
if "--auto-refresh" not in args:
return None

value = parse_named_arg(args, "--auto-refresh")
# Bare flag, or immediately followed by another flag ("--auto-refresh --port 7070"):
# parse_named_arg would hand back "--port", so treat both as "use the default".
if value is None or value.startswith("--"):
return DEFAULT_AUTO_REFRESH
try:
return max(0, int(value))
except ValueError:
print(f"Invalid --auto-refresh value: {value!r} (expected seconds, or 0 to disable)")
sys.exit(2)


def main():
"""Console entry point (``claude-usage``) and ``python cli.py`` dispatch."""
if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
Expand All @@ -487,6 +541,7 @@ def main():
port=parse_named_arg(rest, "--port"),
no_browser="--no-browser" in rest,
surface=parse_named_arg(rest, "--surface"),
auto_refresh=parse_auto_refresh(rest),
)
elif command == "scan" and projects_dir:
cmd_scan(projects_dir=projects_dir)
Expand Down
2 changes: 1 addition & 1 deletion scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# runtime version has to live here as a constant. Keep this in lockstep with the
# top CHANGELOG heading and vscode-extension/package.json (a parity test guards
# all three; see tests/test_version.py).
VERSION = "1.5.5"
VERSION = "1.5.6"

PROJECTS_DIR = Path.home() / ".claude" / "projects"
XCODE_PROJECTS_DIR = Path.home() / "Library" / "Developer" / "Xcode" / "CodingAssistant" / "ClaudeAgentConfig" / "projects"
Expand Down
85 changes: 84 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Tests for cli.py - pricing, formatting, and cost calculation."""

import io
import threading
import unittest
from contextlib import redirect_stdout
from unittest import mock
import cli
from cli import get_pricing, calc_cost, fmt, fmt_cost, PRICING
from cli import get_pricing, calc_cost, fmt, fmt_cost, PRICING, parse_auto_refresh


class TestGetPricing(unittest.TestCase):
Expand Down Expand Up @@ -215,5 +216,87 @@ def test_no_browser_suppresses_webbrowser(self):
mock_serve.assert_called_once()


class TestParseAutoRefresh(unittest.TestCase):
"""--auto-refresh SECONDS / --no-auto-refresh resolution."""

def test_absent_returns_none_so_caller_can_use_env_then_default(self):
self.assertIsNone(parse_auto_refresh(["--port", "7070"]))

def test_explicit_seconds(self):
self.assertEqual(parse_auto_refresh(["--auto-refresh", "120"]), 120)

def test_bare_flag_uses_default(self):
self.assertEqual(parse_auto_refresh(["--auto-refresh"]), cli.DEFAULT_AUTO_REFRESH)

def test_flag_followed_by_another_flag_uses_default(self):
# parse_named_arg would hand back "--port" here; it must not be read as a value.
self.assertEqual(parse_auto_refresh(["--auto-refresh", "--port", "7070"]),
cli.DEFAULT_AUTO_REFRESH)

def test_no_auto_refresh_disables(self):
self.assertEqual(parse_auto_refresh(["--no-auto-refresh"]), 0)

def test_no_auto_refresh_wins_over_auto_refresh(self):
self.assertEqual(parse_auto_refresh(["--auto-refresh", "60", "--no-auto-refresh"]), 0)

def test_zero_disables(self):
self.assertEqual(parse_auto_refresh(["--auto-refresh", "0"]), 0)

def test_negative_clamps_to_disabled(self):
self.assertEqual(parse_auto_refresh(["--auto-refresh", "-5"]), 0)

def test_non_numeric_exits(self):
with self.assertRaises(SystemExit), redirect_stdout(io.StringIO()):
parse_auto_refresh(["--auto-refresh", "soon"])


class TestDashboardAutoRefreshLoop(unittest.TestCase):
"""The background thread keeps rescanning, so a long-lived dashboard stays live.

Without the loop, /api/data serves whatever the single startup scan committed —
the browser's 30s poll then re-renders the same frozen snapshot forever.
"""

def _run(self, auto_refresh, stop_after, timeout=5):
"""Drive cmd_dashboard with scan/serve/sleep stubbed. Returns the scan calls."""
calls = []
done = threading.Event()

def fake_scan(*args, **kwargs):
calls.append(kwargs)
if len(calls) >= stop_after:
done.set()
# BaseException, so the loop's `except Exception` won't swallow it:
# unwinds the daemon thread instead of spinning for the whole suite.
raise SystemExit

with mock.patch.object(cli, "cmd_scan", side_effect=fake_scan), \
mock.patch("dashboard.serve"), \
mock.patch("time.sleep", return_value=None), \
redirect_stdout(io.StringIO()):
cli.cmd_dashboard(host="127.0.0.1", port=9999, no_browser=True,
auto_refresh=auto_refresh)
fired = done.wait(timeout=timeout)
return calls, fired

def test_loop_rescans_after_the_startup_scan(self):
calls, fired = self._run(auto_refresh=30, stop_after=3)
self.assertTrue(fired, "background loop never rescanned")
self.assertGreaterEqual(len(calls), 3)

def test_startup_scan_is_verbose_and_rescans_are_quiet(self):
calls, fired = self._run(auto_refresh=30, stop_after=3)
self.assertTrue(fired)
# The startup scan reports the backlog; per-tick rescans must not spam stdout.
self.assertNotIn("verbose", calls[0])
self.assertFalse(calls[1]["verbose"])

def test_zero_disables_the_loop(self):
# Nothing should fire, so a short window is enough — don't stall the suite.
calls, fired = self._run(auto_refresh=0, stop_after=2, timeout=1)
self.assertFalse(fired, "loop ran despite auto_refresh=0")
self.assertEqual(len(calls), 1, "expected only the startup scan")


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "claude-usage-phuryn",
"displayName": "Claude Code Usage by Paweł Huryn",
"description": "Embed your Claude Code usage dashboard (token counts, costs, sessions, projects) directly inside VS Code. Reads local JSONL transcripts, no API calls.",
"version": "1.5.5",
"version": "1.5.6",
"publisher": "PawelHuryn",
"author": {
"name": "Paweł Huryn",
Expand Down