token-counter-mcp 1.0.3 → 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,168 +1,130 @@
1
1
  # Token Counter MCP
2
2
 
3
- An MCP server that accurately counts and tracks every Claude token — input, output, cache read/write, and planning. Uses the official Anthropic token-counting API for **exact** counts (not estimates).
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
- ## Hosted Service — One Command Setup
5
+ ---
6
6
 
7
- We run a hosted instance so you don't need your own Anthropic API key or any deployment:
7
+ ## Quickstart
8
8
 
9
9
  ```bash
10
- claude mcp add --transport sse token-counter https://proud-motivation-production-c4ab.up.railway.app/sse
10
+ npx -y token-counter-mcp setup
11
11
  ```
12
12
 
13
- Restart Claude Code and the tools are ready.
13
+ Restart Claude Code. That's it.
14
14
 
15
- ### Interactive Setup (with Dashboard)
15
+ **What setup does:**
16
+ 1. Registers `token-counter-mcp` globally (`--scope user`) — active in every project
17
+ 2. Creates `~/.claude/token-counter-stop.sh` — a stop hook that logs tokens after each response
18
+ 3. Wires the hook into `~/.claude/settings.json` — no per-project config needed
16
19
 
17
- For a guided setup that also configures the live dashboard token:
20
+ **Dashboard:** open the URL printed at session start (usually `http://localhost:8899`)
18
21
 
19
- ```bash
20
- npx -y token-counter-mcp --setup
21
- ```
22
+ ---
22
23
 
23
- This updates `~/.claude.json` automatically and prints your personal dashboard URL.
24
+ ## Dashboard
24
25
 
25
- ---
26
+ The dashboard gives you a full picture of your token usage with:
26
27
 
27
- ## Available Tools
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)
28
36
 
29
- | Tool | What it does |
30
- |------|-------------|
31
- | `count_tokens` | Exact token count for any text or conversation via Anthropic API |
32
- | `log_usage` | Record actual token usage after an API call (input, output, cache) |
33
- | `get_session_stats` | Running totals and USD cost for the current session |
34
- | `get_usage_history` | Last N usage entries across all sessions |
35
- | `reset_session` | Zero out session totals (history is preserved) |
36
- | `estimate_cost` | Calculate USD cost for a given token count without making an API call |
37
+ The dashboard updates in real-time via Server-Sent Events (SSE).
37
38
 
38
39
  ---
39
40
 
40
- ## Usage in Claude Code
41
+ ## Auto-Tracking
41
42
 
42
- Once added, ask Claude things like:
43
+ After setup, token usage is logged **automatically after every Claude response** — no manual `log_usage` calls needed.
43
44
 
45
+ To check your running cost mid-session, ask Claude:
44
46
  ```
45
- How many tokens is this conversation so far?
46
- Log my last API call: 1500 input, 300 output, claude-opus-4-6
47
47
  What's my total spend this session?
48
- How much would 50k input + 10k output tokens cost on claude-sonnet-4-6?
49
- Show me my usage history for the last 10 entries.
50
- Reset my session totals.
51
48
  ```
52
49
 
53
50
  ---
54
51
 
55
- ## Self-Hosted Deployment
56
-
57
- Want to run your own instance with your own API key?
52
+ ## Available Tools
58
53
 
59
- ### macOS
54
+ | Tool | What it does |
55
+ |------|-------------|
56
+ | `count_tokens` | Count tokens for any text or conversation before sending it |
57
+ | `log_usage` | Manually record token usage (input, output, cache) |
58
+ | `get_session_stats` | Running totals and USD cost for the current session |
59
+ | `get_usage_history` | Last N usage entries across all sessions |
60
+ | `reset_session` | Zero out session totals (history is preserved) |
61
+ | `estimate_cost` | Calculate USD cost for a given token count |
60
62
 
61
- ```bash
62
- # Install Railway CLI
63
- brew install railway
64
-
65
- # Clone and build
66
- git clone https://github.com/krishnakantparashar/TokenCounterMCP
67
- cd TokenCounterMCP
68
- npm install && npm run build
69
-
70
- # Deploy
71
- railway login
72
- railway init
73
- railway up
74
- railway domain
75
-
76
- # Set your Anthropic API key
77
- railway variables set ANTHROPIC_API_KEY=sk-ant-YOUR_KEY_HERE
78
- ```
63
+ ### Token Counting Accuracy
79
64
 
80
- ### Windows (PowerShell)
65
+ | Mode | When | Accuracy |
66
+ |------|------|----------|
67
+ | **Exact** (Anthropic API) | `ANTHROPIC_API_KEY` is set | 100% |
68
+ | **Local** (gpt-tokenizer) | No API key | ~97–99% |
81
69
 
82
- ```powershell
83
- # Install Railway CLI (requires Node.js)
84
- npm install -g @railway/cli
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.
85
71
 
86
- # Clone and build
87
- git clone https://github.com/krishnakantparashar/TokenCounterMCP
88
- cd TokenCounterMCP
89
- npm install
90
- npm run build
72
+ ---
91
73
 
92
- # Deploy
93
- railway login
94
- railway init
95
- railway up
96
- railway domain
74
+ ## Setup Options
97
75
 
98
- # Set your Anthropic API key
99
- railway variables set ANTHROPIC_API_KEY=sk-ant-YOUR_KEY_HERE
100
- ```
76
+ ### Option 1: Automatic (recommended)
101
77
 
102
- After deployment, connect with:
103
78
  ```bash
104
- claude mcp add --transport sse token-counter https://YOUR-URL.up.railway.app/sse
79
+ npx -y token-counter-mcp setup
105
80
  ```
106
81
 
107
- ---
82
+ This registers the MCP globally and installs the auto-tracking stop hook.
108
83
 
109
- ## Local stdio Mode
84
+ ### Option 2: Manual — global (all projects)
110
85
 
111
- Runs entirely on your machine. Requires Node.js 18+.
86
+ ```bash
87
+ claude mcp add --scope user token-counter -- npx -y token-counter-mcp
88
+ ```
112
89
 
113
- ### Quickstart (no clone needed)
90
+ ### Option 3: Manual — per-project only
114
91
 
115
92
  ```bash
116
93
  claude mcp add token-counter -- npx -y token-counter-mcp
117
94
  ```
118
95
 
119
- That's it. Restart Claude Code the server starts on demand via `npx`.
120
-
121
- ### Manual install (if you prefer)
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.
122
97
 
123
- ```bash
124
- git clone https://github.com/krishnakantparashar/TokenCounterMCP
125
- cd TokenCounterMCP
126
- npm install && npm run build
127
- ```
98
+ ### Optional: Exact token counting
128
99
 
129
- Set your API key for exact counts (optional — falls back to ~97–99% accurate local counting without it):
100
+ Set your Anthropic API key to enable 100% exact token counts:
130
101
 
131
102
  ```bash
132
- export ANTHROPIC_API_KEY=sk-ant-YOUR_KEY_HERE # macOS/Linux
133
- $env:ANTHROPIC_API_KEY="sk-ant-YOUR_KEY_HERE" # Windows PowerShell
103
+ export ANTHROPIC_API_KEY=sk-ant-...
134
104
  ```
135
105
 
136
- Add to Claude Code:
106
+ Without it, token counting uses a local approximation (~97–99% accurate). Cost tracking and logging work either way.
137
107
 
138
- ```bash
139
- # macOS/Linux
140
- claude mcp add token-counter -- node "/absolute/path/to/TokenCounterMCP/dist/index.js"
141
-
142
- # Windows
143
- claude mcp add token-counter -- node "C:\path\to\TokenCounterMCP\dist\index.js"
144
- ```
108
+ ---
145
109
 
146
- ### Local Dashboard
110
+ ## Local Dashboard
147
111
 
148
- When running in local stdio mode, a live dashboard is available at:
112
+ The dashboard runs on your machine while Claude Code is active:
149
113
 
150
114
  ```
151
- http://localhost:8899
115
+ Token usage dashboard → http://localhost:8899
152
116
  ```
153
117
 
154
- Open it in your browser to see session totals, per-project cost breakdowns, and usage history.
155
-
156
- ---
157
-
158
- ## Counting Modes
118
+ If port 8899 is taken, it increments automatically (8900, 8901, …). The current port is saved to `~/.claude/token-counter/dashboard-port.txt`.
159
119
 
160
- | Mode | Accuracy | Requires |
161
- |------|----------|----------|
162
- | Exact (Anthropic API) | 100% | `ANTHROPIC_API_KEY` set on server |
163
- | Local approximation | ~97–99% | Nothing — works offline |
120
+ ### Running the dashboard in dev mode
164
121
 
165
- The hosted service uses exact counting. Local mode without a key uses `gpt-tokenizer` (cl100k_base BPE) as a fallback.
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
+ ```
166
128
 
167
129
  ---
168
130
 
@@ -174,23 +136,37 @@ The hosted service uses exact counting. Local mode without a key uses `gpt-token
174
136
  | `claude-sonnet-4-6` | $3.00 / 1M | $15.00 / 1M | $0.30 / 1M | $0.75 / 1M |
175
137
  | `claude-haiku-4-5` | $1.00 / 1M | $5.00 / 1M | $0.10 / 1M | $0.25 / 1M |
176
138
 
177
- Models not in the table fall back to Sonnet pricing. Versioned model IDs (e.g. `claude-opus-4-6-20260101`) are matched by prefix.
139
+ Models not in the table fall back to Sonnet pricing. Versioned IDs (e.g. `claude-sonnet-4-6-20260101`) are matched by prefix.
178
140
 
179
141
  ---
180
142
 
181
- ## Rate Limits
143
+ ## Token Storage
144
+
145
+ Usage is stored locally at `~/.claude/token-counter/`:
182
146
 
183
- The hosted service allows **60 requests per minute** per IP. For higher limits, deploy your own instance.
147
+ | File | Contents |
148
+ |------|----------|
149
+ | `session.json` | Current session totals and entries |
150
+ | `history.json` | All-time log, capped at 10,000 entries |
151
+ | `dashboard-port.txt` | Port the dashboard is currently listening on |
184
152
 
185
153
  ---
186
154
 
187
- ## Token Storage (local mode only)
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
+ ```
188
166
 
189
- Usage history is stored at `~/.claude/token-counter/` (macOS/Linux) or `%USERPROFILE%\.claude\token-counter\` (Windows):
167
+ ---
190
168
 
191
- | File | Contents |
192
- |------|----------|
193
- | `session.json` | Current session totals (reset with `reset_session`) |
194
- | `history.json` | All-time log, capped at 10,000 entries |
169
+ ## Requirements
195
170
 
196
- The hosted service does not persist your usage data between sessions.
171
+ - Node.js 18+
172
+ - Claude Code CLI (`claude`)
@@ -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"}