token-counter-mcp 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Token Counter MCP
2
2
 
3
- Track every Claude token automatically — input, output, cache read/write — with a live local dashboard and per-project cost breakdown. Works in any Claude Code project with zero configuration after a one-time setup.
3
+ Track every Claude token automatically — input, output, cache read/write — with a live analytics dashboard, interactive charts, and per-project cost breakdown. Works in any Claude Code project with zero configuration after a one-time setup.
4
4
 
5
5
  ---
6
6
 
@@ -21,13 +21,26 @@ Restart Claude Code. That's it.
21
21
 
22
22
  ---
23
23
 
24
- ## Auto-Tracking
24
+ ## Dashboard
25
+
26
+ The dashboard gives you a full picture of your token usage with:
25
27
 
26
- After setup, token usage is logged **automatically after every Claude response** — no manual `log_usage` calls needed. The dashboard shows:
28
+ - **Live stats** — total cost, input/output tokens, cache usage, API call count
29
+ - **Interactive charts** (powered by Chart.js):
30
+ - **Spending Trend** — daily cost over the last 30 days (area chart)
31
+ - **Cost by Project** — top projects by spend (doughnut chart)
32
+ - **Token Breakdown** — input vs output tokens per project (stacked bar)
33
+ - **Model Distribution** — usage and cost split by model (doughnut chart)
34
+ - **Project cards** — folder-wise breakdown with cost %, token counts, session history, and expandable detail panels
35
+ - **Paginated call log** — 10 entries per page with navigation, model tags color-coded by tier (opus/sonnet/haiku)
27
36
 
28
- - Session totals (input / output / cache / cost)
29
- - Per-project breakdown (costs grouped by project folder)
30
- - Full usage history
37
+ The dashboard updates in real-time via Server-Sent Events (SSE).
38
+
39
+ ---
40
+
41
+ ## Auto-Tracking
42
+
43
+ After setup, token usage is logged **automatically after every Claude response** — no manual `log_usage` calls needed.
31
44
 
32
45
  To check your running cost mid-session, ask Claude:
33
46
  ```
@@ -40,39 +53,78 @@ What's my total spend this session?
40
53
 
41
54
  | Tool | What it does |
42
55
  |------|-------------|
43
- | `count_tokens` | Estimate token count for any text before reading it |
56
+ | `count_tokens` | Count tokens for any text or conversation before sending it |
44
57
  | `log_usage` | Manually record token usage (input, output, cache) |
45
58
  | `get_session_stats` | Running totals and USD cost for the current session |
46
59
  | `get_usage_history` | Last N usage entries across all sessions |
47
60
  | `reset_session` | Zero out session totals (history is preserved) |
48
61
  | `estimate_cost` | Calculate USD cost for a given token count |
49
62
 
63
+ ### Token Counting Accuracy
64
+
65
+ | Mode | When | Accuracy |
66
+ |------|------|----------|
67
+ | **Exact** (Anthropic API) | `ANTHROPIC_API_KEY` is set | 100% |
68
+ | **Local** (gpt-tokenizer) | No API key | ~97–99% |
69
+
70
+ The local mode uses the `cl100k_base` BPE tokenizer which closely matches Claude's tokenizer. Cost calculations are always exact for the token counts reported.
71
+
50
72
  ---
51
73
 
52
- ## Manual Install (alternative to setup)
74
+ ## Setup Options
75
+
76
+ ### Option 1: Automatic (recommended)
77
+
78
+ ```bash
79
+ npx -y token-counter-mcp setup
80
+ ```
81
+
82
+ This registers the MCP globally and installs the auto-tracking stop hook.
53
83
 
54
- If you prefer to register the MCP without the stop hook:
84
+ ### Option 2: Manual global (all projects)
55
85
 
56
86
  ```bash
57
87
  claude mcp add --scope user token-counter -- npx -y token-counter-mcp
58
88
  ```
59
89
 
60
- Or per-project only:
90
+ ### Option 3: Manual — per-project only
91
+
61
92
  ```bash
62
93
  claude mcp add token-counter -- npx -y token-counter-mcp
63
94
  ```
64
95
 
96
+ > **Note:** Options 2 and 3 skip the stop hook, so you won't get automatic tracking. You'll need to call `log_usage` manually or ask Claude to log usage after each task.
97
+
98
+ ### Optional: Exact token counting
99
+
100
+ Set your Anthropic API key to enable 100% exact token counts:
101
+
102
+ ```bash
103
+ export ANTHROPIC_API_KEY=sk-ant-...
104
+ ```
105
+
106
+ Without it, token counting uses a local approximation (~97–99% accurate). Cost tracking and logging work either way.
107
+
65
108
  ---
66
109
 
67
110
  ## Local Dashboard
68
111
 
69
- The dashboard runs on your machine while Claude Code is active. The actual port is printed at session start:
112
+ The dashboard runs on your machine while Claude Code is active:
70
113
 
71
114
  ```
72
115
  Token usage dashboard → http://localhost:8899
73
116
  ```
74
117
 
75
- If port 8899 is taken, it increments automatically (8900, 8901, …). The current port is always saved to `~/.claude/token-counter/dashboard-port.txt` so the stop hook finds it regardless.
118
+ If port 8899 is taken, it increments automatically (8900, 8901, …). The current port is saved to `~/.claude/token-counter/dashboard-port.txt`.
119
+
120
+ ### Running the dashboard in dev mode
121
+
122
+ ```bash
123
+ git clone https://github.com/kunal12203/token-counter-mcp.git
124
+ cd token-counter-mcp
125
+ npm install
126
+ npm run dev
127
+ ```
76
128
 
77
129
  ---
78
130
 
@@ -94,12 +146,26 @@ Usage is stored locally at `~/.claude/token-counter/`:
94
146
 
95
147
  | File | Contents |
96
148
  |------|----------|
97
- | `session.json` | Current session totals |
149
+ | `session.json` | Current session totals and entries |
98
150
  | `history.json` | All-time log, capped at 10,000 entries |
99
151
  | `dashboard-port.txt` | Port the dashboard is currently listening on |
100
152
 
101
153
  ---
102
154
 
155
+ ## Project Structure
156
+
157
+ ```
158
+ src/
159
+ ├── index.ts # MCP server, tool handlers, HTTP/SSE endpoints
160
+ ├── dashboard.ts # Dashboard HTML with charts and pagination
161
+ ├── storage.ts # Token usage persistence (session + history)
162
+ ├── tokenizer.ts # Token counting (Anthropic API or local BPE)
163
+ ├── costs.ts # Model pricing and cost calculation
164
+ └── setup.ts # One-time setup script
165
+ ```
166
+
167
+ ---
168
+
103
169
  ## Requirements
104
170
 
105
171
  - Node.js 18+
@@ -0,0 +1,2 @@
1
+ export declare const DASHBOARD_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Token Counter \u2014 Dashboard</title>\n <script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js\"></script>\n <style>\n :root {\n --bg: #06080e;\n --surface: rgba(255,255,255,0.03);\n --surface-hover: rgba(255,255,255,0.055);\n --surface-raised: rgba(255,255,255,0.045);\n --border: rgba(255,255,255,0.06);\n --border-bright: rgba(255,255,255,0.13);\n --text: #f1f5f9;\n --text-sec: #94a3b8;\n --text-dim: #475569;\n --purple: #a78bfa;\n --purple-dim: rgba(167,139,250,0.10);\n --blue: #38bdf8;\n --blue-dim: rgba(56,189,248,0.08);\n --pink: #f472b6;\n --pink-dim: rgba(244,114,182,0.10);\n --green: #34d399;\n --green-dim: rgba(52,211,153,0.10);\n --amber: #fbbf24;\n --amber-dim: rgba(251,191,36,0.10);\n --red: #f87171;\n --red-dim: rgba(248,113,113,0.10);\n --cyan: #22d3ee;\n --indigo: #818cf8;\n --radius: 14px;\n --radius-sm: 10px;\n }\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;\n background: var(--bg);\n color: var(--text);\n min-height: 100vh;\n -webkit-font-smoothing: antialiased;\n overflow-x: hidden;\n }\n body::before {\n content: '';\n position: fixed; inset: 0; z-index: 0; pointer-events: none;\n background:\n radial-gradient(ellipse 800px 600px at 10% 10%, rgba(167,139,250,0.06) 0%, transparent 60%),\n radial-gradient(ellipse 600px 800px at 90% 90%, rgba(56,189,248,0.04) 0%, transparent 60%),\n radial-gradient(ellipse 500px 400px at 75% 5%, rgba(244,114,182,0.035) 0%, transparent 50%),\n radial-gradient(ellipse 400px 400px at 40% 80%, rgba(52,211,153,0.025) 0%, transparent 50%);\n }\n .wrap { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; padding: 24px 20px 80px; }\n\n /* \u2500\u2500 Header \u2500\u2500 */\n .hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 32px; gap: 12px; flex-wrap: wrap; }\n .hdr-left { display: flex; align-items: center; gap: 14px; }\n .logo {\n width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0;\n background: linear-gradient(135deg, #a78bfa 0%, #38bdf8 50%, #f472b6 100%);\n display: flex; align-items: center; justify-content: center;\n font-size: 20px; box-shadow: 0 0 24px rgba(167,139,250,0.25), 0 0 48px rgba(56,189,248,0.1);\n }\n .hdr h1 { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.03em; color: var(--text); }\n .hdr-sub { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }\n .badge {\n display: flex; align-items: center; gap: 6px;\n border-radius: 100px; padding: 4px 12px;\n font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;\n background: var(--green-dim); border: 1px solid rgba(52,211,153,0.2);\n color: var(--green); transition: all 0.3s;\n }\n .badge.off { background: var(--red-dim); border-color: rgba(248,113,113,0.2); color: var(--red); }\n .badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 2s ease-in-out infinite; }\n @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.7)} }\n .hdr-right { text-align: right; }\n .hdr-time { font-size: 0.72rem; color: var(--text-dim); line-height: 1.5; }\n\n /* \u2500\u2500 Section titles \u2500\u2500 */\n .sec-title {\n font-size: 0.62rem; font-weight: 700; letter-spacing: 0.12em;\n text-transform: uppercase; color: var(--text-dim); margin-bottom: 12px;\n display: flex; align-items: center; gap: 8px;\n }\n .sec-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }\n\n /* \u2500\u2500 Stat cards \u2500\u2500 */\n .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; margin-bottom: 32px; }\n .stat-card {\n background: var(--surface); border: 1px solid var(--border);\n border-radius: var(--radius); padding: 18px 16px;\n transition: border-color 0.2s, background 0.2s, transform 0.15s;\n position: relative; overflow: hidden;\n }\n .stat-card:hover { border-color: var(--border-bright); background: var(--surface-hover); transform: translateY(-1px); }\n .stat-card::before {\n content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;\n background: linear-gradient(90deg, transparent, var(--purple), transparent);\n opacity: 0; transition: opacity 0.3s;\n }\n .stat-card:hover::before { opacity: 1; }\n .stat-lbl { font-size: 0.62rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.09em; color: var(--text-dim); margin-bottom: 10px; }\n .stat-val { font-size: 1.65rem; font-weight: 700; letter-spacing: -0.04em; line-height: 1; color: var(--text); }\n .stat-val.grad {\n background: linear-gradient(130deg, #a78bfa 0%, #f472b6 50%, #38bdf8 100%);\n -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;\n }\n .stat-note { font-size: 0.58rem; color: var(--text-dim); margin-top: 6px; }\n\n /* \u2500\u2500 Charts \u2500\u2500 */\n .charts-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 32px; }\n @media (max-width: 768px) { .charts-grid { grid-template-columns: 1fr; } }\n .chart-card {\n background: var(--surface); border: 1px solid var(--border);\n border-radius: var(--radius); padding: 20px;\n transition: border-color 0.2s;\n }\n .chart-card:hover { border-color: var(--border-bright); }\n .chart-title { font-size: 0.74rem; font-weight: 600; color: var(--text-sec); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }\n .chart-title-icon { font-size: 0.85rem; opacity: 0.6; }\n .chart-wrap { position: relative; height: 220px; }\n .chart-wrap canvas { width: 100% !important; height: 100% !important; }\n .chart-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-dim); font-size: 0.78rem; }\n .no-charts-msg { text-align: center; padding: 40px 20px; color: var(--text-dim); font-size: 0.82rem; border: 1px dashed var(--border); border-radius: var(--radius); margin-bottom: 32px; }\n\n /* \u2500\u2500 Projects \u2500\u2500 */\n .projects-section { margin-bottom: 32px; }\n .proj-card {\n background: var(--surface); border: 1px solid var(--border);\n border-radius: var(--radius); margin-bottom: 8px; overflow: hidden;\n transition: border-color 0.2s, box-shadow 0.3s;\n }\n .proj-card:hover { border-color: var(--border-bright); box-shadow: 0 4px 24px rgba(0,0,0,0.15); }\n .proj-hdr {\n display: flex; align-items: center; padding: 14px 18px;\n cursor: pointer; gap: 14px; user-select: none;\n transition: background 0.15s;\n }\n .proj-hdr:hover { background: rgba(255,255,255,0.015); }\n .proj-icon {\n width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0;\n display: flex; align-items: center; justify-content: center; font-size: 16px;\n border: 1px solid rgba(167,139,250,0.12);\n }\n .proj-icon.tier-high { background: linear-gradient(135deg, rgba(167,139,250,0.2), rgba(244,114,182,0.15)); }\n .proj-icon.tier-mid { background: linear-gradient(135deg, rgba(56,189,248,0.15), rgba(52,211,153,0.1)); }\n .proj-icon.tier-low { background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); }\n .proj-info { flex: 1; min-width: 0; }\n .proj-name { font-size: 0.88rem; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .proj-path { font-size: 0.64rem; color: var(--text-dim); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'SF Mono', 'Fira Code', monospace; }\n .proj-metrics { display: flex; align-items: center; gap: 24px; flex-shrink: 0; }\n .proj-metric { text-align: right; }\n .proj-metric-val { font-size: 0.88rem; font-weight: 700; }\n .proj-metric-val.cost { color: var(--purple); }\n .proj-metric-val.tokens { color: var(--blue); }\n .proj-metric-lbl { font-size: 0.58rem; color: var(--text-dim); margin-top: 2px; letter-spacing: 0.03em; }\n .chevron {\n width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim);\n transition: transform 0.25s ease;\n }\n .proj-card.open .chevron { transform: rotate(90deg); }\n .proj-bar-wrap { height: 3px; background: rgba(255,255,255,0.04); margin: 0 18px; border-radius: 3px; overflow: hidden; }\n .proj-bar { height: 100%; border-radius: 3px; transition: width 0.6s ease; }\n .proj-bar.tier-high { background: linear-gradient(90deg, #a78bfa, #f472b6); }\n .proj-bar.tier-mid { background: linear-gradient(90deg, #38bdf8, #34d399); }\n .proj-bar.tier-low { background: linear-gradient(90deg, rgba(148,163,184,0.4), rgba(148,163,184,0.2)); }\n\n /* Project detail panel */\n .proj-detail { display: none; border-top: 1px solid var(--border); }\n .proj-card.open .proj-detail { display: block; }\n .proj-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }\n .proj-detail-grid > div { background: var(--bg); padding: 14px 18px; }\n .proj-detail-label { font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 4px; }\n .proj-detail-value { font-size: 0.92rem; font-weight: 600; color: var(--text-sec); }\n .proj-sessions-title { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); padding: 12px 18px 6px; }\n .sess-row {\n display: flex; align-items: center; gap: 12px;\n padding: 10px 18px 10px 18px;\n border-bottom: 1px solid rgba(255,255,255,0.03);\n transition: background 0.15s;\n }\n .sess-row:last-child { border-bottom: none; }\n .sess-row:hover { background: rgba(255,255,255,0.015); }\n .sess-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--text-dim); }\n .sess-dot.active { background: var(--green); box-shadow: 0 0 8px rgba(52,211,153,0.5); }\n .sess-time { font-size: 0.76rem; color: var(--text-sec); flex: 1; }\n .sess-tokens { font-size: 0.68rem; color: var(--text-dim); }\n .sess-calls { font-size: 0.68rem; color: var(--text-dim); min-width: 50px; text-align: right; }\n .sess-cost { font-size: 0.82rem; font-weight: 600; color: var(--purple); min-width: 72px; text-align: right; }\n\n /* \u2500\u2500 Table \u2500\u2500 */\n .tbl-section { margin-bottom: 32px; }\n .tbl-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }\n table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }\n thead th {\n padding: 11px 14px; text-align: left; white-space: nowrap;\n font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;\n color: var(--text-dim); background: rgba(0,0,0,0.2); border-bottom: 1px solid var(--border);\n }\n tbody td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,0.03); white-space: nowrap; vertical-align: middle; }\n tbody tr:last-child td { border-bottom: none; }\n tbody tr { transition: background 0.12s; }\n tbody tr:hover td { background: rgba(255,255,255,0.018); }\n .model-tag {\n display: inline-block; padding: 2px 8px; border-radius: 6px;\n font-size: 0.66rem; font-weight: 600;\n }\n .model-tag.opus { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(167,139,250,0.12); }\n .model-tag.sonnet { background: var(--blue-dim); color: var(--blue); border: 1px solid rgba(56,189,248,0.12); }\n .model-tag.haiku { background: var(--green-dim); color: var(--green); border: 1px solid rgba(52,211,153,0.12); }\n .model-tag.other { background: rgba(255,255,255,0.04); color: var(--text-sec); border: 1px solid var(--border); }\n .cost-cell { color: var(--purple); font-weight: 600; }\n .dim { color: var(--text-dim); }\n .sec { color: var(--text-sec); }\n @keyframes flash-in { 0% { background: rgba(167,139,250,0.1); } 100% { background: transparent; } }\n .flash td { animation: flash-in 1.8s ease forwards; }\n .empty-cell { text-align: center; padding: 48px 24px; }\n .empty-icon { font-size: 1.8rem; opacity: 0.25; margin-bottom: 10px; }\n .empty-text { font-size: 0.8rem; color: var(--text-dim); }\n .no-proj { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; text-align: center; color: var(--text-dim); font-size: 0.82rem; line-height: 1.7; }\n .no-proj code { background: rgba(255,255,255,0.06); border-radius: 4px; padding: 1px 6px; font-family: 'SF Mono', monospace; font-size: 0.78rem; color: var(--text-sec); }\n\n /* \u2500\u2500 Pagination \u2500\u2500 */\n .pager {\n display: flex; align-items: center; justify-content: space-between;\n padding: 12px 16px; border-top: 1px solid var(--border);\n background: rgba(0,0,0,0.12);\n }\n .pager-info { font-size: 0.7rem; color: var(--text-dim); }\n .pager-btns { display: flex; align-items: center; gap: 4px; }\n .pager-btn {\n width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border);\n background: var(--surface); color: var(--text-sec);\n font-size: 0.72rem; font-weight: 600; cursor: pointer;\n display: flex; align-items: center; justify-content: center;\n transition: all 0.15s;\n }\n .pager-btn:hover:not(:disabled) { border-color: var(--border-bright); background: var(--surface-hover); color: var(--text); }\n .pager-btn:disabled { opacity: 0.3; cursor: default; }\n .pager-btn.active { background: var(--purple-dim); border-color: rgba(167,139,250,0.3); color: var(--purple); }\n .pager-btn.nav { font-size: 0.82rem; width: 36px; }\n .pager-ellipsis { width: 24px; text-align: center; color: var(--text-dim); font-size: 0.7rem; }\n\n /* \u2500\u2500 Footer \u2500\u2500 */\n .footer { text-align: center; padding: 24px; font-size: 0.65rem; color: var(--text-dim); }\n .footer a { color: var(--purple); text-decoration: none; }\n\n /* \u2500\u2500 Scrollbar \u2500\u2500 */\n ::-webkit-scrollbar { width: 5px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }\n ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }\n\n /* \u2500\u2500 Responsive \u2500\u2500 */\n @media (max-width: 640px) {\n .stats-grid { grid-template-columns: repeat(2, 1fr); }\n .proj-metrics { gap: 14px; }\n .proj-metric:nth-child(n+3) { display: none; }\n thead th:nth-child(4), tbody td:nth-child(4),\n thead th:nth-child(7), tbody td:nth-child(7) { display: none; }\n }\n </style>\n</head>\n<body>\n<div class=\"wrap\">\n\n <!-- Header -->\n <div class=\"hdr\">\n <div class=\"hdr-left\">\n <div class=\"logo\">&#x2B21;</div>\n <div>\n <h1>Token Counter</h1>\n <div class=\"hdr-sub\" id=\"session-line\">Connecting&hellip;</div>\n </div>\n <div class=\"badge\" id=\"badge\"><div class=\"badge-dot\"></div><span id=\"badge-txt\">LIVE</span></div>\n </div>\n <div class=\"hdr-right\">\n <div class=\"hdr-time\" id=\"hdr-time\"></div>\n </div>\n </div>\n\n <!-- Stats -->\n <div class=\"sec-title\" id=\"sess-section-label\">Current Session</div>\n <div class=\"stats-grid\">\n <div class=\"stat-card\"><div class=\"stat-lbl\">Total Cost</div><div class=\"stat-val grad\" id=\"c-cost\">&mdash;</div><div class=\"stat-note\" id=\"c-cost-note\"></div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Input Tokens</div><div class=\"stat-val\" id=\"c-in\">&mdash;</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Output Tokens</div><div class=\"stat-val\" id=\"c-out\">&mdash;</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Cache Read</div><div class=\"stat-val\" id=\"c-cr\">&mdash;</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Cache Write</div><div class=\"stat-val\" id=\"c-cw\">&mdash;</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">API Calls</div><div class=\"stat-val\" id=\"c-n\">&mdash;</div><div class=\"stat-note\" id=\"c-proj-count\"></div></div>\n </div>\n\n <!-- Charts -->\n <div class=\"sec-title\">Analytics</div>\n <div id=\"charts-section\">\n <div class=\"charts-grid\">\n <div class=\"chart-card\">\n <div class=\"chart-title\"><span class=\"chart-title-icon\">&#x1F4C8;</span> Spending Trend</div>\n <div class=\"chart-wrap\"><canvas id=\"chart-trend\"></canvas></div>\n </div>\n <div class=\"chart-card\">\n <div class=\"chart-title\"><span class=\"chart-title-icon\">&#x1F4C1;</span> Cost by Project</div>\n <div class=\"chart-wrap\"><canvas id=\"chart-proj-cost\"></canvas></div>\n </div>\n <div class=\"chart-card\">\n <div class=\"chart-title\"><span class=\"chart-title-icon\">&#x1F4CA;</span> Token Breakdown</div>\n <div class=\"chart-wrap\"><canvas id=\"chart-tokens\"></canvas></div>\n </div>\n <div class=\"chart-card\">\n <div class=\"chart-title\"><span class=\"chart-title-icon\">&#x2699;</span> Model Distribution</div>\n <div class=\"chart-wrap\"><canvas id=\"chart-models\"></canvas></div>\n </div>\n </div>\n </div>\n\n <!-- Projects -->\n <div class=\"projects-section\">\n <div class=\"sec-title\">Projects</div>\n <div id=\"proj-list\"><div class=\"no-proj\">Loading&hellip;</div></div>\n </div>\n\n <!-- Recent calls -->\n <div class=\"tbl-section\">\n <div class=\"sec-title\">Recent Calls</div>\n <div class=\"tbl-wrap\">\n <table>\n <thead>\n <tr><th>Time</th><th>Model</th><th>Project</th><th>Description</th><th>Input</th><th>Output</th><th>Cache R/W</th><th>Cost</th></tr>\n </thead>\n <tbody id=\"tbody\">\n <tr><td colspan=\"8\"><div class=\"empty-cell\"><div class=\"empty-icon\">&#x25CE;</div><div class=\"empty-text\">Waiting for first log_usage call&hellip;</div></div></td></tr>\n </tbody>\n </table>\n <div class=\"pager\" id=\"pager\" style=\"display:none\">\n <div class=\"pager-info\" id=\"pager-info\"></div>\n <div class=\"pager-btns\" id=\"pager-btns\"></div>\n </div>\n </div>\n </div>\n\n <div class=\"footer\">Token Counter MCP &middot; Real-time usage tracking</div>\n</div>\n\n<script>\n(function() {\n 'use strict';\n\n // \u2500\u2500 Chart.js check \u2500\u2500\n var hasChartJs = typeof Chart !== 'undefined';\n if (!hasChartJs) {\n document.getElementById('charts-section').innerHTML = '<div class=\"no-charts-msg\">Charts require internet connection (Chart.js CDN). Live stats are still updating.</div>';\n }\n\n // \u2500\u2500 Chart.js global defaults \u2500\u2500\n if (hasChartJs) {\n Chart.defaults.color = '#64748b';\n Chart.defaults.borderColor = 'rgba(255,255,255,0.04)';\n Chart.defaults.font.family = \"-apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif\";\n Chart.defaults.font.size = 11;\n Chart.defaults.plugins.legend.labels.padding = 12;\n Chart.defaults.plugins.legend.labels.usePointStyle = true;\n Chart.defaults.plugins.legend.labels.pointStyleWidth = 8;\n }\n\n var COLORS = ['#a78bfa','#38bdf8','#f472b6','#34d399','#fbbf24','#f87171','#818cf8','#22d3ee','#fb923c','#a3e635'];\n var COLORS_DIM = COLORS.map(function(c) { return c + '18'; });\n\n // \u2500\u2500 Utility functions \u2500\u2500\n function fmt(n) { n = n || 0; if (n >= 1e6) return (n/1e6).toFixed(1)+'M'; if (n >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); }\n function fmtCost(u) { if (!u || u < 0.0001) return '~\\$0.00'; var s = parseFloat(u.toPrecision(2)); if (s < 0.01) return '~\\$'+s.toFixed(4); if (s < 1) return '~\\$'+s.toFixed(3); return '~\\$'+s.toFixed(2); }\n function fmtCostShort(u) { if (!u || u < 0.001) return '\\$0'; if (u < 1) return '\\$'+u.toFixed(2); return '\\$'+u.toFixed(1); }\n function hhmm(ts) { return new Date(ts).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); }\n function dtFmt(ts) { var d = new Date(ts); var today = new Date(); var isToday = d.toDateString() === today.toDateString(); var t = d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); return isToday ? 'Today '+t : d.toLocaleDateString([], {month:'short', day:'numeric'})+' '+t; }\n function mdl(m) { return m ? m.replace('claude-','').replace(/-\\d{8}$/, '') : '\\u2014'; }\n function mdlClass(m) { if (!m) return 'other'; if (m.indexOf('opus') !== -1) return 'opus'; if (m.indexOf('sonnet') !== -1) return 'sonnet'; if (m.indexOf('haiku') !== -1) return 'haiku'; return 'other'; }\n function projBase(p) { if (!p || p === '(no project)') return '\\u2014'; var parts = p.split('/').filter(Boolean); return parts[parts.length - 1] || p; }\n function dateFmt(iso) { var d = new Date(iso); return d.toLocaleDateString([], {month:'short', day:'numeric'}); }\n\n // \u2500\u2500 State \u2500\u2500\n var token = new URLSearchParams(location.search).get('token') || '';\n var isRemote = !!token;\n var LS_KEY = 'tc_v2_' + token;\n var localEntries = [];\n var allHistory = [];\n var openProjects = {};\n\n var badge = document.getElementById('badge');\n var badgeTxt = document.getElementById('badge-txt');\n var sessionLine = document.getElementById('session-line');\n var hdrTime = document.getElementById('hdr-time');\n\n // Chart instances\n var chartTrend = null;\n var chartProjCost = null;\n var chartTokens = null;\n var chartModels = null;\n\n if (isRemote) {\n document.getElementById('sess-section-label').textContent = 'All Time Stats';\n try { localEntries = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); } catch(e) {}\n if (localEntries.length) {\n var st = buildState(localEntries);\n renderState(st, false);\n updateCharts(localEntries, st.grouped);\n }\n }\n\n // \u2500\u2500 SSE \u2500\u2500\n var sseUrl = isRemote ? '/events?token=' + encodeURIComponent(token) : '/events';\n var es = new EventSource(sseUrl);\n es.onerror = function() { badge.className = 'badge off'; badgeTxt.textContent = 'OFFLINE'; };\n es.onopen = function() { badge.className = 'badge'; badgeTxt.textContent = 'LIVE'; };\n\n es.onmessage = function(ev) {\n var d = JSON.parse(ev.data);\n if (isRemote) {\n var map = {};\n localEntries.forEach(function(e) { map[e.id] = e; });\n (d.entries || []).forEach(function(e) { map[e.id] = e; });\n localEntries = Object.values(map).sort(function(a, b) { return a.timestamp < b.timestamp ? -1 : 1; });\n if (localEntries.length > 1000) localEntries = localEntries.slice(-1000);\n try { localStorage.setItem(LS_KEY, JSON.stringify(localEntries)); } catch(e) {}\n var st = buildState(localEntries);\n renderState(st, true);\n updateCharts(localEntries, st.grouped);\n } else {\n renderLocalState(d);\n var entries = (d.history || d.session.entries || []);\n updateCharts(entries, d.grouped || []);\n }\n };\n\n // \u2500\u2500 Build state (remote mode) \u2500\u2500\n function buildState(entries) {\n var totals = {inputTokens:0, outputTokens:0, cacheReadTokens:0, cacheWriteTokens:0, totalCost:0};\n entries.forEach(function(e) {\n totals.inputTokens += e.inputTokens || 0;\n totals.outputTokens += e.outputTokens || 0;\n totals.cacheReadTokens += e.cacheReadTokens || 0;\n totals.cacheWriteTokens += e.cacheWriteTokens || 0;\n totals.totalCost += e.totalCost || 0;\n });\n var pm = {};\n entries.forEach(function(e) {\n var proj = e.project || '(no project)';\n if (!pm[proj]) pm[proj] = {};\n var sid = e.sessionId || 'default';\n if (!pm[proj][sid]) pm[proj][sid] = { entries: [], startedAt: e.timestamp };\n pm[proj][sid].entries.push(e);\n });\n var grouped = Object.keys(pm).map(function(project) {\n var sm = pm[project];\n var allE = [];\n var sessions = Object.keys(sm).map(function(sid) {\n var s = sm[sid];\n allE = allE.concat(s.entries);\n return {\n sessionId: sid, startedAt: s.startedAt,\n totalInputTokens: s.entries.reduce(function(a, e) { return a + (e.inputTokens||0); }, 0),\n totalOutputTokens: s.entries.reduce(function(a, e) { return a + (e.outputTokens||0); }, 0),\n totalCost: s.entries.reduce(function(a, e) { return a + (e.totalCost||0); }, 0),\n entryCount: s.entries.length,\n };\n }).sort(function(a, b) { return b.startedAt.localeCompare(a.startedAt); });\n var parts = project.split('/').filter(Boolean);\n return {\n project: project, sessions: sessions,\n displayName: project === '(no project)' ? '(no project)' : parts[parts.length-1] || project,\n totalCost: allE.reduce(function(a, e) { return a + (e.totalCost||0); }, 0),\n totalInputTokens: allE.reduce(function(a, e) { return a + (e.inputTokens||0); }, 0),\n totalOutputTokens: allE.reduce(function(a, e) { return a + (e.outputTokens||0); }, 0),\n lastActiveAt: sessions[0] ? sessions[0].startedAt : '',\n };\n }).sort(function(a, b) { return b.totalCost - a.totalCost; });\n return { totals: totals, grouped: grouped, entries: entries };\n }\n\n // \u2500\u2500 Render: remote mode \u2500\u2500\n function renderState(st, isLive) {\n var t = st.totals;\n var n = st.entries.length;\n sessionLine.textContent = n + ' call' + (n===1?'':'s') + ' \\u00B7 ' + st.grouped.length + ' project' + (st.grouped.length===1?'':'s') + (isLive ? '' : ' \\u00B7 cached');\n hdrTime.innerHTML = 'Updated ' + new Date().toLocaleTimeString();\n updateStatCards(t, n, st.grouped.length);\n renderProjects(st.grouped, null);\n renderTable(st.entries.slice().reverse());\n }\n\n // \u2500\u2500 Render: local mode \u2500\u2500\n function renderLocalState(d) {\n var sess = d.session, t = sess.totals, entries = sess.entries || [], grouped = d.grouped || [];\n var started = new Date(sess.startedAt);\n sessionLine.textContent = 'Session since ' + started.toLocaleTimeString() + ' \\u00B7 ' + entries.length + ' call' + (entries.length===1?'':'s');\n hdrTime.innerHTML = 'Updated ' + new Date().toLocaleTimeString() + '<br><span style=\"color:var(--text-dim);font-size:.64rem\">' + started.toLocaleDateString([], {weekday:'short', month:'short', day:'numeric'}) + '</span>';\n updateStatCards(t, entries.length, grouped.length);\n renderProjects(grouped, sess.sessionId);\n renderTable(entries.slice().reverse());\n }\n\n function updateStatCards(t, callCount, projCount) {\n document.getElementById('c-cost').textContent = fmtCost(t.totalCost);\n document.getElementById('c-in').textContent = fmt(t.inputTokens);\n document.getElementById('c-out').textContent = fmt(t.outputTokens);\n document.getElementById('c-cr').textContent = fmt(t.cacheReadTokens);\n document.getElementById('c-cw').textContent = fmt(t.cacheWriteTokens);\n document.getElementById('c-n').textContent = callCount;\n document.getElementById('c-proj-count').textContent = projCount + ' project' + (projCount === 1 ? '' : 's');\n }\n\n // \u2500\u2500 Chart data aggregators \u2500\u2500\n function aggregateByDate(entries) {\n var byDate = {};\n entries.forEach(function(e) {\n var date = e.timestamp ? e.timestamp.slice(0, 10) : '';\n if (!date) return;\n if (!byDate[date]) byDate[date] = 0;\n byDate[date] += e.totalCost || 0;\n });\n var result = [];\n var today = new Date();\n for (var i = 29; i >= 0; i--) {\n var d = new Date(today);\n d.setDate(d.getDate() - i);\n var key = d.toISOString().slice(0, 10);\n result.push({ date: key, label: dateFmt(key), cost: byDate[key] || 0 });\n }\n return result;\n }\n\n function aggregateByModel(entries) {\n var byModel = {};\n entries.forEach(function(e) {\n var m = mdl(e.model || 'unknown');\n if (!byModel[m]) byModel[m] = { count: 0, cost: 0, tokens: 0 };\n byModel[m].count++;\n byModel[m].cost += e.totalCost || 0;\n byModel[m].tokens += (e.inputTokens || 0) + (e.outputTokens || 0);\n });\n return Object.keys(byModel).map(function(m) {\n return { model: m, count: byModel[m].count, cost: byModel[m].cost, tokens: byModel[m].tokens };\n }).sort(function(a, b) { return b.cost - a.cost; });\n }\n\n function aggregateTokensByProject(grouped) {\n return grouped.slice(0, 8).map(function(g) {\n return {\n name: g.displayName,\n input: g.totalInputTokens || 0,\n output: g.totalOutputTokens || 0,\n };\n });\n }\n\n // \u2500\u2500 Chart rendering \u2500\u2500\n function updateCharts(entries, grouped) {\n if (!hasChartJs || !entries.length) return;\n\n // 1. Spending Trend (area line chart)\n var trendData = aggregateByDate(entries);\n var trendLabels = trendData.map(function(d) { return d.label; });\n var trendValues = trendData.map(function(d) { return d.cost; });\n\n if (!chartTrend) {\n var ctx1 = document.getElementById('chart-trend').getContext('2d');\n var gradient1 = ctx1.createLinearGradient(0, 0, 0, 220);\n gradient1.addColorStop(0, 'rgba(167,139,250,0.25)');\n gradient1.addColorStop(1, 'rgba(167,139,250,0.0)');\n chartTrend = new Chart(ctx1, {\n type: 'line',\n data: {\n labels: trendLabels,\n datasets: [{\n label: 'Daily Cost',\n data: trendValues,\n borderColor: '#a78bfa',\n backgroundColor: gradient1,\n fill: true,\n tension: 0.4,\n borderWidth: 2,\n pointRadius: 0,\n pointHoverRadius: 5,\n pointHoverBackgroundColor: '#a78bfa',\n pointHoverBorderColor: '#fff',\n pointHoverBorderWidth: 2,\n }]\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n interaction: { mode: 'index', intersect: false },\n plugins: {\n legend: { display: false },\n tooltip: {\n backgroundColor: 'rgba(15,17,25,0.95)',\n borderColor: 'rgba(167,139,250,0.3)',\n borderWidth: 1,\n titleColor: '#f1f5f9',\n bodyColor: '#94a3b8',\n padding: 10,\n callbacks: {\n label: function(ctx) { return '\\$' + ctx.parsed.y.toFixed(4); }\n }\n }\n },\n scales: {\n x: {\n grid: { display: false },\n ticks: { maxTicksLimit: 7, font: { size: 10 } }\n },\n y: {\n grid: { color: 'rgba(255,255,255,0.03)' },\n ticks: { font: { size: 10 }, callback: function(v) { return '\\$' + v.toFixed(2); } }\n }\n }\n }\n });\n } else {\n chartTrend.data.labels = trendLabels;\n chartTrend.data.datasets[0].data = trendValues;\n chartTrend.update('none');\n }\n\n // 2. Cost by Project (doughnut)\n var projData = grouped.slice(0, 8).filter(function(g) { return g.totalCost > 0; });\n var projLabels = projData.map(function(g) { return g.displayName; });\n var projValues = projData.map(function(g) { return g.totalCost; });\n\n if (!chartProjCost) {\n var ctx2 = document.getElementById('chart-proj-cost').getContext('2d');\n chartProjCost = new Chart(ctx2, {\n type: 'doughnut',\n data: {\n labels: projLabels,\n datasets: [{\n data: projValues,\n backgroundColor: COLORS.slice(0, projValues.length),\n borderColor: 'rgba(6,8,14,0.8)',\n borderWidth: 2,\n hoverBorderColor: '#fff',\n hoverBorderWidth: 2,\n }]\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n cutout: '62%',\n plugins: {\n legend: {\n position: 'right',\n labels: { font: { size: 10 }, padding: 8, boxWidth: 10 }\n },\n tooltip: {\n backgroundColor: 'rgba(15,17,25,0.95)',\n borderColor: 'rgba(167,139,250,0.3)',\n borderWidth: 1,\n padding: 10,\n callbacks: {\n label: function(ctx) { return ' ' + ctx.label + ': \\$' + ctx.parsed.toFixed(4); }\n }\n }\n }\n }\n });\n } else {\n chartProjCost.data.labels = projLabels;\n chartProjCost.data.datasets[0].data = projValues;\n chartProjCost.data.datasets[0].backgroundColor = COLORS.slice(0, projValues.length);\n chartProjCost.update('none');\n }\n\n // 3. Token Breakdown by Project (stacked horizontal bar)\n var tokenData = aggregateTokensByProject(grouped);\n var tokenLabels = tokenData.map(function(d) { return d.name; });\n if (!tokenLabels.length) tokenLabels = ['No data'];\n\n if (!chartTokens) {\n var ctx3 = document.getElementById('chart-tokens').getContext('2d');\n chartTokens = new Chart(ctx3, {\n type: 'bar',\n data: {\n labels: tokenLabels,\n datasets: [\n { label: 'Input', data: tokenData.map(function(d) { return d.input; }), backgroundColor: '#a78bfa', borderRadius: 3 },\n { label: 'Output', data: tokenData.map(function(d) { return d.output; }), backgroundColor: '#38bdf8', borderRadius: 3 },\n ]\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n indexAxis: 'y',\n plugins: {\n legend: { position: 'top', labels: { font: { size: 10 }, padding: 8, boxWidth: 10 } },\n tooltip: {\n backgroundColor: 'rgba(15,17,25,0.95)',\n borderColor: 'rgba(167,139,250,0.3)',\n borderWidth: 1,\n padding: 10,\n callbacks: {\n label: function(ctx) { return ' ' + ctx.dataset.label + ': ' + fmt(ctx.parsed.x); }\n }\n }\n },\n scales: {\n x: {\n stacked: true,\n grid: { color: 'rgba(255,255,255,0.03)' },\n ticks: { font: { size: 10 }, callback: function(v) { return fmt(v); } }\n },\n y: {\n stacked: true,\n grid: { display: false },\n ticks: { font: { size: 10 } }\n }\n }\n }\n });\n } else {\n chartTokens.data.labels = tokenLabels;\n chartTokens.data.datasets[0].data = tokenData.map(function(d) { return d.input; });\n chartTokens.data.datasets[1].data = tokenData.map(function(d) { return d.output; });\n chartTokens.update('none');\n }\n\n // 4. Model Distribution (doughnut)\n var modelData = aggregateByModel(entries);\n var modelLabels = modelData.map(function(d) { return d.model; });\n var modelValues = modelData.map(function(d) { return d.cost; });\n\n if (!chartModels) {\n var ctx4 = document.getElementById('chart-models').getContext('2d');\n chartModels = new Chart(ctx4, {\n type: 'doughnut',\n data: {\n labels: modelLabels,\n datasets: [{\n data: modelValues,\n backgroundColor: ['#a78bfa','#38bdf8','#34d399','#fbbf24','#f472b6','#f87171'],\n borderColor: 'rgba(6,8,14,0.8)',\n borderWidth: 2,\n hoverBorderColor: '#fff',\n hoverBorderWidth: 2,\n }]\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n cutout: '62%',\n plugins: {\n legend: {\n position: 'right',\n labels: { font: { size: 10 }, padding: 8, boxWidth: 10 }\n },\n tooltip: {\n backgroundColor: 'rgba(15,17,25,0.95)',\n borderColor: 'rgba(167,139,250,0.3)',\n borderWidth: 1,\n padding: 10,\n callbacks: {\n label: function(ctx) { return ' ' + ctx.label + ': \\$' + ctx.parsed.toFixed(4) + ' (' + modelData[ctx.dataIndex].count + ' calls)'; }\n }\n }\n }\n }\n });\n } else {\n chartModels.data.labels = modelLabels;\n chartModels.data.datasets[0].data = modelValues;\n chartModels.update('none');\n }\n }\n\n // \u2500\u2500 Render projects \u2500\u2500\n function renderProjects(grouped, currentSessId) {\n var pl = document.getElementById('proj-list');\n if (!grouped || !grouped.length) {\n pl.innerHTML = '<div class=\"no-proj\">No project data yet.<br>Pass a <code>project</code> param to <code>log_usage</code> to track by folder.</div>';\n return;\n }\n var maxCost = Math.max.apply(null, grouped.map(function(g) { return g.totalCost; }).concat([0.001]));\n var totalCost = grouped.reduce(function(a, g) { return a + g.totalCost; }, 0);\n\n pl.innerHTML = grouped.map(function(g) {\n var isOpen = openProjects[g.project];\n var barPct = Math.max(4, Math.round(g.totalCost / maxCost * 100));\n var costPct = totalCost > 0 ? ((g.totalCost / totalCost) * 100).toFixed(1) : '0';\n var tier = g.totalCost / maxCost > 0.5 ? 'high' : (g.totalCost / maxCost > 0.15 ? 'mid' : 'low');\n var totalTokens = (g.totalInputTokens || 0) + (g.totalOutputTokens || 0);\n\n var sessHtml = (g.sessions || []).map(function(s) {\n var isActive = currentSessId && s.sessionId === currentSessId;\n return '<div class=\"sess-row\">' +\n '<div class=\"sess-dot' + (isActive ? ' active' : '') + '\"></div>' +\n '<span class=\"sess-time\">' + dtFmt(s.startedAt) + '</span>' +\n '<span class=\"sess-tokens\">' + fmt((s.totalInputTokens||0)+(s.totalOutputTokens||0)) + ' tok</span>' +\n '<span class=\"sess-calls\">' + s.entryCount + ' call' + (s.entryCount===1?'':'s') + '</span>' +\n '<span class=\"sess-cost\">' + fmtCost(s.totalCost) + '</span>' +\n '</div>';\n }).join('');\n\n return '<div class=\"proj-card' + (isOpen ? ' open' : '') + '\" data-proj=\"' + g.project + '\">' +\n '<div class=\"proj-hdr\" onclick=\"window._toggleProj(this.parentElement)\">' +\n '<div class=\"proj-icon tier-' + tier + '\">&#x1F4C1;</div>' +\n '<div class=\"proj-info\">' +\n '<div class=\"proj-name\">' + g.displayName + '</div>' +\n '<div class=\"proj-path\">' + g.project + '</div>' +\n '</div>' +\n '<div class=\"proj-metrics\">' +\n '<div class=\"proj-metric\"><div class=\"proj-metric-val cost\">' + fmtCost(g.totalCost) + '</div><div class=\"proj-metric-lbl\">cost (' + costPct + '%)</div></div>' +\n '<div class=\"proj-metric\"><div class=\"proj-metric-val tokens\">' + fmt(totalTokens) + '</div><div class=\"proj-metric-lbl\">tokens</div></div>' +\n '<div class=\"proj-metric\"><div class=\"proj-metric-val\" style=\"color:var(--text-sec)\">' + (g.sessions||[]).length + '</div><div class=\"proj-metric-lbl\">sessions</div></div>' +\n '</div>' +\n '<svg class=\"chevron\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2.5\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9 5l7 7-7 7\"/></svg>' +\n '</div>' +\n '<div class=\"proj-bar-wrap\"><div class=\"proj-bar tier-' + tier + '\" style=\"width:' + barPct + '%\"></div></div>' +\n '<div class=\"proj-detail\">' +\n '<div class=\"proj-detail-grid\">' +\n '<div><div class=\"proj-detail-label\">Input Tokens</div><div class=\"proj-detail-value\">' + fmt(g.totalInputTokens) + '</div></div>' +\n '<div><div class=\"proj-detail-label\">Output Tokens</div><div class=\"proj-detail-value\">' + fmt(g.totalOutputTokens) + '</div></div>' +\n '<div><div class=\"proj-detail-label\">Total Cost</div><div class=\"proj-detail-value\" style=\"color:var(--purple)\">' + fmtCost(g.totalCost) + '</div></div>' +\n '<div><div class=\"proj-detail-label\">Last Active</div><div class=\"proj-detail-value\">' + (g.lastActiveAt ? dtFmt(g.lastActiveAt) : '\\u2014') + '</div></div>' +\n '</div>' +\n '<div class=\"proj-sessions-title\">Sessions</div>' +\n sessHtml +\n '</div>' +\n '</div>';\n }).join('');\n }\n\n // \u2500\u2500 Pagination state \u2500\u2500\n var PAGE_SIZE = 10;\n var currentPage = 1;\n var allRows = [];\n\n function renderTable(rows) {\n allRows = rows || [];\n // When new data arrives, stay on page 1 if new entries were added, otherwise keep current page\n var totalPages = Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));\n if (currentPage > totalPages) currentPage = totalPages;\n renderTablePage();\n }\n\n function renderTablePage() {\n var tbody = document.getElementById('tbody');\n var pager = document.getElementById('pager');\n\n if (!allRows.length) {\n tbody.innerHTML = '<tr><td colspan=\"8\"><div class=\"empty-cell\"><div class=\"empty-icon\">&#x25CE;</div><div class=\"empty-text\">Waiting for first log_usage call&hellip;</div></div></td></tr>';\n pager.style.display = 'none';\n return;\n }\n\n var totalPages = Math.ceil(allRows.length / PAGE_SIZE);\n var start = (currentPage - 1) * PAGE_SIZE;\n var end = Math.min(start + PAGE_SIZE, allRows.length);\n var pageRows = allRows.slice(start, end);\n\n tbody.innerHTML = pageRows.map(function(en, i) {\n var mc = mdlClass(en.model);\n var isFirst = (currentPage === 1 && i === 0);\n return '<tr class=\"' + (isFirst ? 'flash' : '') + '\">' +\n '<td class=\"dim\">' + hhmm(en.timestamp) + '</td>' +\n '<td><span class=\"model-tag ' + mc + '\">' + mdl(en.model) + '</span></td>' +\n '<td class=\"dim\" title=\"' + (en.project || '') + '\">' + projBase(en.project) + '</td>' +\n '<td class=\"' + (en.description ? 'sec' : 'dim') + '\">' + (en.description || '\\u2014') + '</td>' +\n '<td class=\"sec\">' + fmt(en.inputTokens) + '</td>' +\n '<td class=\"sec\">' + fmt(en.outputTokens) + '</td>' +\n '<td class=\"dim\">' + fmt(en.cacheReadTokens) + '/' + fmt(en.cacheWriteTokens) + '</td>' +\n '<td class=\"cost-cell\">' + fmtCost(en.totalCost) + '</td>' +\n '</tr>';\n }).join('');\n\n // Pagination controls\n if (totalPages <= 1) {\n pager.style.display = 'flex';\n document.getElementById('pager-info').textContent = 'Showing ' + allRows.length + ' of ' + allRows.length + ' calls';\n document.getElementById('pager-btns').innerHTML = '';\n return;\n }\n\n pager.style.display = 'flex';\n document.getElementById('pager-info').textContent = 'Showing ' + (start+1) + '\\u2013' + end + ' of ' + allRows.length + ' calls';\n\n var btns = '';\n // Prev button\n btns += '<button class=\"pager-btn nav\" ' + (currentPage <= 1 ? 'disabled' : '') + ' onclick=\"window._goPage(' + (currentPage-1) + ')\" title=\"Previous\">&#x2039;</button>';\n\n // Page number buttons with smart ellipsis\n var pages = buildPageNumbers(currentPage, totalPages);\n for (var i = 0; i < pages.length; i++) {\n if (pages[i] === '...') {\n btns += '<span class=\"pager-ellipsis\">&#x2026;</span>';\n } else {\n var p = pages[i];\n btns += '<button class=\"pager-btn' + (p === currentPage ? ' active' : '') + '\" onclick=\"window._goPage(' + p + ')\">' + p + '</button>';\n }\n }\n\n // Next button\n btns += '<button class=\"pager-btn nav\" ' + (currentPage >= totalPages ? 'disabled' : '') + ' onclick=\"window._goPage(' + (currentPage+1) + ')\" title=\"Next\">&#x203A;</button>';\n\n document.getElementById('pager-btns').innerHTML = btns;\n }\n\n function buildPageNumbers(current, total) {\n if (total <= 7) {\n var arr = [];\n for (var i = 1; i <= total; i++) arr.push(i);\n return arr;\n }\n var pages = [];\n pages.push(1);\n if (current > 3) pages.push('...');\n var rangeStart = Math.max(2, current - 1);\n var rangeEnd = Math.min(total - 1, current + 1);\n for (var j = rangeStart; j <= rangeEnd; j++) pages.push(j);\n if (current < total - 2) pages.push('...');\n pages.push(total);\n return pages;\n }\n\n window._goPage = function(page) {\n var totalPages = Math.ceil(allRows.length / PAGE_SIZE);\n if (page < 1 || page > totalPages) return;\n currentPage = page;\n renderTablePage();\n // Scroll table into view\n document.querySelector('.tbl-section').scrollIntoView({ behavior: 'smooth', block: 'start' });\n };\n\n // \u2500\u2500 Project toggle \u2500\u2500\n window._toggleProj = function(card) {\n var proj = card.getAttribute('data-proj');\n if (card.classList.contains('open')) {\n card.classList.remove('open');\n delete openProjects[proj];\n } else {\n card.classList.add('open');\n openProjects[proj] = true;\n }\n };\n\n})();\n</script>\n</body>\n</html>";
2
+ //# sourceMappingURL=dashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.d.ts","sourceRoot":"","sources":["../src/dashboard.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,cAAc,g+5CA66BnB,CAAC"}