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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 51 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ That's it. It reads your local data, renders a heatmap in your terminal, and exp

## Supported Tools

| Tool | Data Source | What's Tracked |
|------|-----------|----------------|
| **Claude Code** | `~/.claude/stats-cache.json` | Tokens, models, sessions, costs |
| **Codex CLI** | `~/.codex/sessions/*.jsonl` | Tokens, models, session durations |
| **OpenCode** | `~/.local/share/opencode/` | Tokens, models, messages |
| **Cursor** | Cursor API + local `state.vscdb` | Tokens, models, usage events |
| Tool | Data Source | What's Tracked |
| --------------- | -------------------------------- | --------------------------------- |
| **Claude Code** | `~/.claude/stats-cache.json` | Tokens, models, sessions, costs |
| **Codex CLI** | `~/.codex/sessions/*.jsonl` | Tokens, models, session durations |
| **OpenCode** | `~/.local/share/opencode/` | Tokens, models, messages |
| **Cursor** | Cursor API + local `state.vscdb` | Tokens, models, usage events |

tokenviz auto-detects which tools you have installed. No configuration needed.

Expand Down Expand Up @@ -79,6 +79,22 @@ A full-color contribution grid right in your terminal, with:
- Average session length
- Per-tool usage panels

### Cost Analysis

See exactly how much your AI usage costs with `--cost`:

```
💰 ESTIMATED COST

MODEL INPUT OUTPUT CACHE READ CACHE WRITE TOTAL
claude-opus-4-6 $0.37 $5.65 $33.9 $59.7 $99.5
claude-sonnet-4-6 $0.03 $1.58 $4.06 $5.48 $11.2

TOTAL $116
```

Breaks down cost per model with input, output, cache read, and cache write columns. Pricing is based on official published API rates.

### Shareable PNG/SVG

Automatically exports a high-quality image you can share on Twitter, LinkedIn, your blog, or anywhere.
Expand Down Expand Up @@ -125,6 +141,11 @@ tokenviz --copy
# Dump raw stats as JSON (for scripting)
tokenviz --json

# Show estimated cost breakdown by model
tokenviz --cost
tokenviz --claude --cost
tokenviz --cost --no-export

# See all themes
tokenviz --list-themes
```
Expand All @@ -133,13 +154,13 @@ tokenviz --list-themes

10 built-in themes — 5 light, 5 dark:

| Dark | Light |
|------|-------|
| `dark-ember` | `green` (default) |
| `dark-green` | `purple` |
| `dark-purple` | `blue` |
| `dark-blue` | `amber` |
| `dark-mono` | `mono` |
| Dark | Light |
| ------------- | ----------------- |
| `dark-ember` | `green` (default) |
| `dark-green` | `purple` |
| `dark-purple` | `blue` |
| `dark-blue` | `amber` |
| `dark-mono` | `mono` |

```bash
tokenviz --theme dark-purple
Expand All @@ -148,21 +169,22 @@ tokenviz --theme amber

## Options

| Flag | Description | Default |
|------|-------------|---------|
| `--user <name>` | Username shown on the heatmap | — |
| `--claude` | Include only Claude Code data | — |
| `--codex` | Include only Codex data | — |
| `--opencode` | Include only OpenCode data | — |
| `--cursor` | Include only Cursor data | — |
| `--theme <name>` | Color theme | `green` |
| `--export <fmt>` | Export format: `png` or `svg` | `png` |
| `--no-export` | Skip file export, terminal only | — |
| `--out <path>` | Custom output file path | `tokenviz.png` |
| `--copy` | Copy PNG to clipboard after export | — |
| `--year <year>` | Filter to a specific year | last 365 days |
| `--json` | Output raw stats as JSON | — |
| `--list-themes` | Show all available themes | — |
| Flag | Description | Default |
| ---------------- | -------------------------------------- | -------------- |
| `--user <name>` | Username shown on the heatmap | — |
| `--claude` | Include only Claude Code data | — |
| `--codex` | Include only Codex data | — |
| `--opencode` | Include only OpenCode data | — |
| `--cursor` | Include only Cursor data | — |
| `--theme <name>` | Color theme | `green` |
| `--export <fmt>` | Export format: `png` or `svg` | `png` |
| `--no-export` | Skip file export, terminal only | — |
| `--out <path>` | Custom output file path | `tokenviz.png` |
| `--copy` | Copy PNG to clipboard after export | — |
| `--year <year>` | Filter to a specific year | last 365 days |
| `--json` | Output raw stats as JSON | — |
| `--cost` | Show estimated cost breakdown by model | — |
| `--list-themes` | Show all available themes | — |

## How It Works

Expand All @@ -183,6 +205,7 @@ tokenviz reads **locally stored data** from your AI coding tools. It never sends

**Q: I don't see any data?**
Make sure you've actually used one of the supported tools. tokenviz reads from the default data locations — if you've customized paths, set the environment variable:

- `CLAUDE_CONFIG_DIR` for Claude Code
- `CODEX_HOME` for Codex CLI
- `OPENCODE_DATA_DIR` for OpenCode
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tokenviz",
"version": "0.3.0",
"version": "0.3.1",
"description": "GitHub-style contribution heatmap for your AI coding tool usage. Supports Claude Code, Codex, OpenCode & Cursor.",
"type": "module",
"bin": {
Expand Down Expand Up @@ -46,7 +46,7 @@
"bugs": {
"url": "https://github.com/harshkedia177/tokenviz/issues"
},
"author": "Harsh Kedia",
"author": "Harsh Kedia & Akshat Shaw",
"dependencies": {
"@resvg/resvg-js": "^2.6.2",
"chalk": "^5.4.0",
Expand Down
89 changes: 84 additions & 5 deletions src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'path';
import { claudePaths } from '../lib/paths.js';
import { poolMap } from '../lib/concurrency.js';
import type { DayData, AdapterResult } from '../types.js';
import type { ModelTokenDetail } from '../pricing.js';

const FILE_CONCURRENCY = parseInt(process.env.BRAGGRID_CONCURRENCY ?? '', 10) || 32;

Expand Down Expand Up @@ -40,6 +41,7 @@ interface DayAccum {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
models: Record<string, number>;
hours: Record<number, number>;
sessions: Set<string>;
Expand Down Expand Up @@ -68,6 +70,7 @@ interface ParsedRecord {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
model: string;
hour: number;
sessionId?: string;
Expand Down Expand Up @@ -104,14 +107,15 @@ function parseLines(content: string, yearPrefix: string | null): Map<string, Par
const date = timestamp.slice(0, 10);
if (yearPrefix && !date.startsWith(yearPrefix)) continue;

const inputTokens = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
const inputTokens = usage.input_tokens || 0;
const cacheWriteTokens = usage.cache_creation_input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;

if (inputTokens + outputTokens + cacheReadTokens === 0) continue;
if (inputTokens + cacheWriteTokens + outputTokens + cacheReadTokens === 0) continue;

const parsed: ParsedRecord = {
date, inputTokens, outputTokens, cacheReadTokens, model,
date, inputTokens: inputTokens + cacheWriteTokens, outputTokens, cacheReadTokens, cacheWriteTokens, model,
hour: extractHour(timestamp),
sessionId: record.sessionId,
};
Expand Down Expand Up @@ -145,12 +149,13 @@ function accumulateRecords(records: Map<string, ParsedRecord>, dayMap: Map<strin

let entry = dayMap.get(rec.date);
if (!entry) {
entry = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, models: {}, hours: {}, sessions: new Set(), messages: 0 };
entry = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, models: {}, hours: {}, sessions: new Set(), messages: 0 };
dayMap.set(rec.date, entry);
}
entry.inputTokens += rec.inputTokens;
entry.outputTokens += rec.outputTokens;
entry.cacheReadTokens += rec.cacheReadTokens;
entry.cacheWriteTokens += rec.cacheWriteTokens;

const modelTotal = rec.inputTokens + rec.cacheReadTokens + rec.outputTokens;
entry.models[rec.model] = (entry.models[rec.model] || 0) + modelTotal;
Expand Down Expand Up @@ -200,6 +205,7 @@ async function loadFromJsonl(dirs: string[], yearFilter: number | null): Promise
const days: DayData[] = [];
const hourCounts: Record<string, number> = {};
const modelUsage: Record<string, number> = {};
const detailedModelUsage: Record<string, ModelTokenDetail> = {};
let totalSessions = 0;
let totalMessages = 0;
let firstDate: string | null = null;
Expand Down Expand Up @@ -228,6 +234,23 @@ async function loadFromJsonl(dirs: string[], yearFilter: number | null): Promise
}
}

// Build detailedModelUsage by proportional distribution of day-level in/out/cache
// across models based on their share of total tokens per day
for (const [, entry] of dayMap) {
const dayTotal = entry.inputTokens + entry.outputTokens + entry.cacheReadTokens;
if (dayTotal === 0) continue;
for (const [model, modelTotal] of Object.entries(entry.models)) {
const ratio = modelTotal / dayTotal;
if (!detailedModelUsage[model]) {
detailedModelUsage[model] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
}
detailedModelUsage[model].inputTokens += Math.round((entry.inputTokens - entry.cacheWriteTokens) * ratio);
detailedModelUsage[model].outputTokens += Math.round(entry.outputTokens * ratio);
detailedModelUsage[model].cacheReadTokens += Math.round(entry.cacheReadTokens * ratio);
detailedModelUsage[model].cacheWriteTokens += Math.round(entry.cacheWriteTokens * ratio);
}
}

return {
tool: 'claude',
days,
Expand All @@ -236,6 +259,7 @@ async function loadFromJsonl(dirs: string[], yearFilter: number | null): Promise
totalMessages,
firstSessionDate: firstDate,
modelUsage,
detailedModelUsage,
avgSessionSeconds: 0,
};
}
Expand All @@ -249,6 +273,7 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult

const days: DayData[] = [];
const modelUsage: Record<string, number> = {};
const detailedModelUsage: Record<string, ModelTokenDetail> = {};
let firstDate: string | null = null;

for (const [date, models] of Object.entries(costDays)) {
Expand Down Expand Up @@ -277,6 +302,17 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult
for (const [model, tokens] of Object.entries(dayModels)) {
modelUsage[model] = (modelUsage[model] || 0) + tokens;
}

// Track detailed per-model token breakdown from cost cache
for (const [modelId, usage] of Object.entries(models)) {
if (!detailedModelUsage[modelId]) {
detailedModelUsage[modelId] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
}
detailedModelUsage[modelId].inputTokens += usage.input || 0;
detailedModelUsage[modelId].outputTokens += usage.output || 0;
detailedModelUsage[modelId].cacheReadTokens += usage.cacheRead || 0;
detailedModelUsage[modelId].cacheWriteTokens += usage.cacheWrite || 0;
}
}

if (days.length === 0) return null;
Expand All @@ -289,6 +325,7 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult
totalMessages: 0,
firstSessionDate: firstDate,
modelUsage,
detailedModelUsage,
avgSessionSeconds: 0,
};
}
Expand All @@ -302,6 +339,7 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR

const days: DayData[] = [];
const modelUsage: Record<string, number> = {};
const detailedModelUsage: Record<string, ModelTokenDetail> = {};
let firstDate: string | null = null;

for (const [date, entry] of Object.entries(statsCache)) {
Expand All @@ -322,6 +360,15 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR
cacheReadTokens += cacheRead;
const modelTotal = input + cacheRead + output;
dayModels[modelId] = (dayModels[modelId] || 0) + modelTotal;

// Track detailed per-model token breakdown
if (!detailedModelUsage[modelId]) {
detailedModelUsage[modelId] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
}
detailedModelUsage[modelId].inputTokens += usage.inputTokens || 0;
detailedModelUsage[modelId].outputTokens += output;
detailedModelUsage[modelId].cacheReadTokens += cacheRead;
detailedModelUsage[modelId].cacheWriteTokens += usage.cacheCreationTokens || 0;
}

if (inputTokens + outputTokens + cacheReadTokens === 0) continue;
Expand All @@ -344,16 +391,48 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR
totalMessages: 0,
firstSessionDate: firstDate,
modelUsage,
detailedModelUsage,
avgSessionSeconds: 0,
};
}

/**
* Enrich detailedModelUsage from stats-cache.json's top-level modelUsage,
* which has authoritative per-model token breakdowns including cacheCreationInputTokens.
*/
function enrichFromStatsCache(result: AdapterResult, dirs: string[]): void {
const raw = loadJson(dirs, 'stats-cache.json');
if (!raw) return;

const modelUsage = raw.modelUsage as Record<string, Record<string, number>> | undefined;
if (!modelUsage) return;

// Only replace if we have authoritative data
const enriched: Record<string, ModelTokenDetail> = {};
for (const [model, usage] of Object.entries(modelUsage)) {
enriched[model] = {
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadTokens: usage.cacheReadInputTokens || 0,
cacheWriteTokens: usage.cacheCreationInputTokens || 0,
};
}

if (Object.keys(enriched).length > 0) {
result.detailedModelUsage = enriched;
}
}

export async function load(yearFilter: number | null): Promise<AdapterResult | null> {
const dirs = claudePaths();
if (!dirs.length) return null;

const jsonlResult = await loadFromJsonl(dirs, yearFilter);
if (jsonlResult) return jsonlResult;
if (jsonlResult) {
// Enrich with stats-cache.json for accurate per-model token breakdowns
enrichFromStatsCache(jsonlResult, dirs);
return jsonlResult;
}

// Try stats-cache.json (more detailed than readout-cost-cache)
const statsCacheResult = loadFromStatsCache(dirs, yearFilter);
Expand Down
1 change: 1 addition & 0 deletions src/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function toAggregatedData(name: string, result: AdapterResult): AggregatedData {
totalMessages: result.totalMessages || 0,
firstSessionDate: result.firstSessionDate || null,
modelUsage,
detailedModelUsage: result.detailedModelUsage || {},
avgSessionSeconds: result.avgSessionSeconds || 0,
};
}
Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface CLIOptions {
json?: boolean;
listThemes?: boolean;
verbose?: boolean;
cost?: boolean;
}

program
Expand All @@ -66,6 +67,7 @@ program
.option('--json', 'Output raw stats as JSON')
.option('--list-themes', 'Show all available themes')
.option('--verbose', 'Show debug output for troubleshooting')
.option('--cost', 'Show estimated cost breakdown by model')
.action(async (opts: CLIOptions) => {
try {
if (opts.listThemes) {
Expand Down Expand Up @@ -114,7 +116,7 @@ program
return;
}

const renderOpts = { theme: opts.theme, user: opts.user, year: opts.year };
const renderOpts = { theme: opts.theme, user: opts.user, year: opts.year, showCost: opts.cost };

renderTerminal(panels, renderOpts);

Expand Down
Loading