token-counter-mcp 1.0.4 → 1.0.6
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 +79 -13
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +945 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/index.js +4 -418
- package/dist/index.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +106 -28
- package/dist/setup.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
##
|
|
24
|
+
## Dashboard
|
|
25
|
+
|
|
26
|
+
The dashboard gives you a full picture of your token usage with:
|
|
25
27
|
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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` |
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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\">⬡</div>\n <div>\n <h1>Token Counter</h1>\n <div class=\"hdr-sub\" id=\"session-line\">Connecting…</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\">—</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\">—</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Output Tokens</div><div class=\"stat-val\" id=\"c-out\">—</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Cache Read</div><div class=\"stat-val\" id=\"c-cr\">—</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">Cache Write</div><div class=\"stat-val\" id=\"c-cw\">—</div></div>\n <div class=\"stat-card\"><div class=\"stat-lbl\">API Calls</div><div class=\"stat-val\" id=\"c-n\">—</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\">📈</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\">📁</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\">📊</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\">⚙</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…</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\">◎</div><div class=\"empty-text\">Waiting for first log_usage call…</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 · 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 + '\">📁</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\">◎</div><div class=\"empty-text\">Waiting for first log_usage call…</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\">‹</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\">…</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\">›</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"}
|