@neuralmindlabs/tokn 0.1.0
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/.github/workflows/ci.yml +22 -0
- package/.oxlintrc.json +8 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/assets/index-Dq74RfU-.css +1 -0
- package/dist/assets/index-_9e8Z58Z.js +66 -0
- package/dist/favicon.svg +1 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +45 -0
- package/packages/tokn/LICENSE +21 -0
- package/packages/tokn/README.md +85 -0
- package/packages/tokn/package.json +56 -0
- package/packages/tokn/src/aggregate.ts +83 -0
- package/packages/tokn/src/cli.ts +181 -0
- package/packages/tokn/src/cursor.ts +85 -0
- package/packages/tokn/src/index.ts +6 -0
- package/packages/tokn/src/node.ts +50 -0
- package/packages/tokn/src/parsers/claude.ts +53 -0
- package/packages/tokn/src/parsers/codex.ts +74 -0
- package/packages/tokn/src/pricing/index.ts +106 -0
- package/packages/tokn/src/pricing/prices.json +948 -0
- package/packages/tokn/src/pricing/prices.ts +947 -0
- package/packages/tokn/src/types.ts +51 -0
- package/packages/tokn/tests/__snapshots__/eval.test.ts.snap +12 -0
- package/packages/tokn/tests/aggregate.test.ts +73 -0
- package/packages/tokn/tests/claude.test.ts +87 -0
- package/packages/tokn/tests/codex.test.ts +54 -0
- package/packages/tokn/tests/cursor.test.ts +126 -0
- package/packages/tokn/tests/eval.test.ts +69 -0
- package/packages/tokn/tests/fixtures/claude-session.jsonl +3 -0
- package/packages/tokn/tests/fixtures/codex-session.jsonl +5 -0
- package/packages/tokn/tests/pricing.test.ts +137 -0
- package/packages/tokn/tsconfig.json +21 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/scripts/validate.ts +122 -0
- package/src/App.tsx +12 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/vite.svg +1 -0
- package/src/index.css +250 -0
- package/src/lib/refresh.ts +134 -0
- package/src/lib/scanner.ts +108 -0
- package/src/main.tsx +7 -0
- package/src/ui/Dashboard.tsx +203 -0
- package/src/ui/Popover.tsx +91 -0
- package/src/ui/Settings.tsx +114 -0
- package/src/ui/useAppData.ts +71 -0
- package/src-tauri/Cargo.lock +5972 -0
- package/src-tauri/Cargo.toml +35 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +114 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/64x64.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/src-tauri/icons/android/values/ic_launcher_background.xml +4 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-20x20@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-29x29@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@2x-1.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-40x40@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-512@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-60x60@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-60x60@3x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-76x76@1x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-76x76@2x.png +0 -0
- package/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/src-tauri/icons/source/app-icon-1024.png +0 -0
- package/src-tauri/icons/source/app-icon.svg +74 -0
- package/src-tauri/icons/source/tray-22.png +0 -0
- package/src-tauri/icons/source/tray-44.png +0 -0
- package/src-tauri/icons/source/tray-icon.svg +14 -0
- package/src-tauri/permissions/allow-custom-commands.toml +11 -0
- package/src-tauri/src/lib.rs +238 -0
- package/src-tauri/src/main.rs +6 -0
- package/src-tauri/tauri.conf.json +57 -0
- package/tsconfig.app.json +27 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +23 -0
- package/vite.config.ts +7 -0
- package/vitest.config.ts +28 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readDir, readTextFile, stat } from '@tauri-apps/plugin-fs';
|
|
2
|
+
import { homeDir, join } from '@tauri-apps/api/path';
|
|
3
|
+
import { parseClaudeSession } from 'tokn';
|
|
4
|
+
import { parseCodexSession } from 'tokn';
|
|
5
|
+
import { eventsToDays, type SourceContribution } from 'tokn';
|
|
6
|
+
import type { FileScanState, Tool } from 'tokn';
|
|
7
|
+
|
|
8
|
+
export interface ScanFileEntry extends FileScanState {
|
|
9
|
+
tool: Tool;
|
|
10
|
+
/** claude only: dedupe keys owned by this file, so a reparse can reclaim them */
|
|
11
|
+
keys?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ScanState = Record<string, ScanFileEntry>;
|
|
15
|
+
|
|
16
|
+
async function listJsonl(dir: string): Promise<string[]> {
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readDir(dir);
|
|
21
|
+
} catch {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const p = await join(dir, e.name);
|
|
26
|
+
if (e.isDirectory) out.push(...(await listJsonl(p)));
|
|
27
|
+
else if (e.name.endsWith('.jsonl')) out.push(p);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Incrementally scan Claude + Codex logs. Unchanged files (same size+mtime)
|
|
34
|
+
* are never re-read; changed files are reparsed and their day contributions
|
|
35
|
+
* replaced. Claude dedupe keys are owned per-file: before reparsing a file its
|
|
36
|
+
* keys are released so resumed sessions don't dedupe against themselves.
|
|
37
|
+
*/
|
|
38
|
+
export async function scan(state: ScanState): Promise<{ state: ScanState; sources: SourceContribution[] }> {
|
|
39
|
+
const home = await homeDir();
|
|
40
|
+
const roots: Array<{ tool: Tool; dir: string }> = [
|
|
41
|
+
{ tool: 'claude', dir: await join(home, '.claude', 'projects') },
|
|
42
|
+
{ tool: 'codex', dir: await join(home, '.codex', 'sessions') },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const next: ScanState = {};
|
|
46
|
+
const globalKeys = new Set<string>();
|
|
47
|
+
for (const entry of Object.values(state)) {
|
|
48
|
+
for (const k of entry.keys ?? []) globalKeys.add(k);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const { tool, dir } of roots) {
|
|
52
|
+
const files = (await listJsonl(dir)).sort();
|
|
53
|
+
for (const path of files) {
|
|
54
|
+
let info;
|
|
55
|
+
try {
|
|
56
|
+
info = await stat(path);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const mtimeMs = info.mtime ? new Date(info.mtime).getTime() : 0;
|
|
61
|
+
const prev = state[path];
|
|
62
|
+
if (prev && prev.size === info.size && prev.mtimeMs === mtimeMs) {
|
|
63
|
+
next[path] = prev;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// release this file's old dedupe keys before reparsing it
|
|
68
|
+
for (const k of prev?.keys ?? []) globalKeys.delete(k);
|
|
69
|
+
|
|
70
|
+
let content: string;
|
|
71
|
+
try {
|
|
72
|
+
content = await readTextFile(path);
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (tool === 'claude') {
|
|
78
|
+
const before = new Set(globalKeys);
|
|
79
|
+
const parsed = parseClaudeSession(content, globalKeys);
|
|
80
|
+
const owned = [...globalKeys].filter((k) => !before.has(k));
|
|
81
|
+
next[path] = {
|
|
82
|
+
tool,
|
|
83
|
+
size: info.size,
|
|
84
|
+
mtimeMs,
|
|
85
|
+
project: parsed.project,
|
|
86
|
+
days: eventsToDays(parsed.events),
|
|
87
|
+
keys: owned,
|
|
88
|
+
};
|
|
89
|
+
} else {
|
|
90
|
+
const parsed = parseCodexSession(content);
|
|
91
|
+
next[path] = {
|
|
92
|
+
tool,
|
|
93
|
+
size: info.size,
|
|
94
|
+
mtimeMs,
|
|
95
|
+
project: parsed.project,
|
|
96
|
+
days: eventsToDays(parsed.events),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sources: SourceContribution[] = Object.values(next).map((f) => ({
|
|
103
|
+
tool: f.tool,
|
|
104
|
+
project: f.project,
|
|
105
|
+
days: f.days,
|
|
106
|
+
}));
|
|
107
|
+
return { state: next, sources };
|
|
108
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client'
|
|
2
|
+
import './index.css'
|
|
3
|
+
import App from './App.tsx'
|
|
4
|
+
|
|
5
|
+
// no StrictMode: the popover window runs the single refresh loop and
|
|
6
|
+
// StrictMode's double-mount in dev would start it twice
|
|
7
|
+
createRoot(document.getElementById('root')!).render(<App />)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
|
3
|
+
import { fmtUSD } from '../lib/refresh.ts';
|
|
4
|
+
import type { Tool, ToolDayTotals } from 'tokn';
|
|
5
|
+
import { totalTokens } from 'tokn';
|
|
6
|
+
import { TOOL_META, TOOLS, fmtTokens, lastNDays, useAppData } from './useAppData.ts';
|
|
7
|
+
import Settings from './Settings.tsx';
|
|
8
|
+
|
|
9
|
+
function sumInto(map: Map<string, ToolDayTotals>, key: string, t: ToolDayTotals) {
|
|
10
|
+
const cur = map.get(key);
|
|
11
|
+
if (!cur) {
|
|
12
|
+
map.set(key, { ...t });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
cur.inputTokens += t.inputTokens;
|
|
16
|
+
cur.outputTokens += t.outputTokens;
|
|
17
|
+
cur.cacheWriteTokens += t.cacheWriteTokens;
|
|
18
|
+
cur.cacheReadTokens += t.cacheReadTokens;
|
|
19
|
+
cur.costUSD += t.costUSD;
|
|
20
|
+
cur.unpricedEvents += t.unpricedEvents;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function Dashboard() {
|
|
24
|
+
const data = useAppData();
|
|
25
|
+
const [range, setRange] = useState(30);
|
|
26
|
+
const [enabled, setEnabled] = useState<Record<Tool, boolean>>({
|
|
27
|
+
claude: true,
|
|
28
|
+
codex: true,
|
|
29
|
+
cursor: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const days = useMemo(() => lastNDays(data?.rollups ?? [], range), [data, range]);
|
|
33
|
+
|
|
34
|
+
const chartData = days.map((d) => {
|
|
35
|
+
const row: any = { date: d.date.slice(5) };
|
|
36
|
+
for (const tool of TOOLS) {
|
|
37
|
+
row[tool] = enabled[tool] ? +(d.byTool[tool]?.costUSD ?? 0).toFixed(4) : 0;
|
|
38
|
+
}
|
|
39
|
+
return row;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const { byModel, byProject, total } = useMemo(() => {
|
|
43
|
+
const byModel = new Map<string, ToolDayTotals>();
|
|
44
|
+
const byProject = new Map<string, ToolDayTotals>();
|
|
45
|
+
let total = 0;
|
|
46
|
+
for (const d of days) {
|
|
47
|
+
for (const tool of TOOLS) {
|
|
48
|
+
if (!enabled[tool]) continue;
|
|
49
|
+
const t = d.byTool[tool];
|
|
50
|
+
if (t) total += t.costUSD;
|
|
51
|
+
}
|
|
52
|
+
for (const [m, t] of Object.entries(d.byModel)) sumInto(byModel, m, t);
|
|
53
|
+
for (const [p, t] of Object.entries(d.byProject)) sumInto(byProject, p, t);
|
|
54
|
+
}
|
|
55
|
+
return { byModel, byProject, total };
|
|
56
|
+
}, [days, enabled]);
|
|
57
|
+
|
|
58
|
+
const exportCSV = () => {
|
|
59
|
+
const rows = [['date', 'tool', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'cost_usd']];
|
|
60
|
+
for (const d of days) {
|
|
61
|
+
for (const tool of TOOLS) {
|
|
62
|
+
const t = d.byTool[tool];
|
|
63
|
+
if (!t) continue;
|
|
64
|
+
rows.push([d.date, tool, ...[t.inputTokens, t.outputTokens, t.cacheWriteTokens, t.cacheReadTokens].map(String), t.costUSD.toFixed(4)]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const blob = new Blob([rows.map((r) => r.join(',')).join('\n')], { type: 'text/csv' });
|
|
68
|
+
const a = document.createElement('a');
|
|
69
|
+
a.href = URL.createObjectURL(blob);
|
|
70
|
+
a.download = `token-usage-${days[0]?.date}-to-${days[days.length - 1]?.date}.csv`;
|
|
71
|
+
a.click();
|
|
72
|
+
URL.revokeObjectURL(a.href);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const sortedModels = [...byModel.entries()].sort((a, b) => b[1].costUSD - a[1].costUSD);
|
|
76
|
+
const sortedProjects = [...byProject.entries()].sort((a, b) => b[1].costUSD - a[1].costUSD).slice(0, 12);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="dashboard">
|
|
80
|
+
<header className="dash-header">
|
|
81
|
+
<div>
|
|
82
|
+
<h1>Token usage</h1>
|
|
83
|
+
<div className="muted">
|
|
84
|
+
Last {range} days · {fmtUSD(total)}
|
|
85
|
+
<span className="muted small"> · Claude/Codex are API-equivalent value; Cursor is billed</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="controls">
|
|
89
|
+
{[30, 90].map((r) => (
|
|
90
|
+
<button key={r} className={range === r ? 'seg active' : 'seg'} onClick={() => setRange(r)}>
|
|
91
|
+
{r}d
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
<button className="seg" onClick={exportCSV}>Export CSV</button>
|
|
95
|
+
</div>
|
|
96
|
+
</header>
|
|
97
|
+
|
|
98
|
+
<div className="filters">
|
|
99
|
+
{TOOLS.map((tool) => (
|
|
100
|
+
<label key={tool} className="filter">
|
|
101
|
+
<input
|
|
102
|
+
type="checkbox"
|
|
103
|
+
checked={enabled[tool]}
|
|
104
|
+
onChange={(e) => setEnabled({ ...enabled, [tool]: e.target.checked })}
|
|
105
|
+
/>
|
|
106
|
+
<span className="dot" style={{ background: TOOL_META[tool].color }} />
|
|
107
|
+
{TOOL_META[tool].label}
|
|
108
|
+
</label>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="chart-box">
|
|
113
|
+
<ResponsiveContainer width="100%" height={260}>
|
|
114
|
+
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
|
115
|
+
<CartesianGrid stroke="var(--hairline)" vertical={false} />
|
|
116
|
+
<XAxis
|
|
117
|
+
dataKey="date"
|
|
118
|
+
tick={{ fontSize: 11, fill: 'var(--muted)' }}
|
|
119
|
+
stroke="var(--border)"
|
|
120
|
+
tickLine={false}
|
|
121
|
+
interval="preserveStartEnd"
|
|
122
|
+
/>
|
|
123
|
+
<YAxis
|
|
124
|
+
tick={{ fontSize: 11, fill: 'var(--muted)' }}
|
|
125
|
+
stroke="transparent"
|
|
126
|
+
tickFormatter={(v: number) => `$${v}`}
|
|
127
|
+
width={48}
|
|
128
|
+
/>
|
|
129
|
+
<Tooltip
|
|
130
|
+
formatter={(v, name) => [fmtUSD(Number(v ?? 0)), TOOL_META[name as Tool]?.label ?? String(name)]}
|
|
131
|
+
cursor={{ fill: 'var(--hover)' }}
|
|
132
|
+
contentStyle={{
|
|
133
|
+
background: 'var(--card)',
|
|
134
|
+
border: '0.5px solid var(--border)',
|
|
135
|
+
borderRadius: 8,
|
|
136
|
+
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.25)',
|
|
137
|
+
fontSize: 12,
|
|
138
|
+
}}
|
|
139
|
+
labelStyle={{ color: 'var(--muted)', marginBottom: 4 }}
|
|
140
|
+
itemStyle={{ color: 'var(--text)', padding: '1px 0' }}
|
|
141
|
+
/>
|
|
142
|
+
{TOOLS.map((tool) => (
|
|
143
|
+
<Bar key={tool} dataKey={tool} stackId="cost" fill={TOOL_META[tool].color} />
|
|
144
|
+
))}
|
|
145
|
+
</BarChart>
|
|
146
|
+
</ResponsiveContainer>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="tables">
|
|
150
|
+
<section>
|
|
151
|
+
<h2>By model</h2>
|
|
152
|
+
<table>
|
|
153
|
+
<thead>
|
|
154
|
+
<tr><th>Model</th><th>Tokens</th><th>Cost</th></tr>
|
|
155
|
+
</thead>
|
|
156
|
+
<tbody>
|
|
157
|
+
{sortedModels.map(([model, t]) => (
|
|
158
|
+
<tr key={model}>
|
|
159
|
+
<td>{model}{t.unpricedEvents > 0 && <span className="warn" title="No pricing for this model; cost not counted"> ⚠︎</span>}</td>
|
|
160
|
+
<td>{fmtTokens(totalTokens(t))}</td>
|
|
161
|
+
<td>{fmtUSD(t.costUSD)}</td>
|
|
162
|
+
</tr>
|
|
163
|
+
))}
|
|
164
|
+
</tbody>
|
|
165
|
+
<tfoot>
|
|
166
|
+
<tr>
|
|
167
|
+
<td>Total</td>
|
|
168
|
+
<td>{fmtTokens(sortedModels.reduce((s, [, t]) => s + totalTokens(t), 0))}</td>
|
|
169
|
+
<td>{fmtUSD(sortedModels.reduce((s, [, t]) => s + t.costUSD, 0))}</td>
|
|
170
|
+
</tr>
|
|
171
|
+
</tfoot>
|
|
172
|
+
</table>
|
|
173
|
+
</section>
|
|
174
|
+
<section>
|
|
175
|
+
<h2>By project</h2>
|
|
176
|
+
<table>
|
|
177
|
+
<thead>
|
|
178
|
+
<tr><th>Project</th><th>Tokens</th><th>Cost</th></tr>
|
|
179
|
+
</thead>
|
|
180
|
+
<tbody>
|
|
181
|
+
{sortedProjects.map(([proj, t]) => (
|
|
182
|
+
<tr key={proj}>
|
|
183
|
+
<td title={proj}>{proj.split('/').slice(-2).join('/')}</td>
|
|
184
|
+
<td>{fmtTokens(totalTokens(t))}</td>
|
|
185
|
+
<td>{fmtUSD(t.costUSD)}</td>
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
<tfoot>
|
|
190
|
+
<tr>
|
|
191
|
+
<td>Total</td>
|
|
192
|
+
<td>{fmtTokens(sortedProjects.reduce((s, [, t]) => s + totalTokens(t), 0))}</td>
|
|
193
|
+
<td>{fmtUSD(sortedProjects.reduce((s, [, t]) => s + t.costUSD, 0))}</td>
|
|
194
|
+
</tr>
|
|
195
|
+
</tfoot>
|
|
196
|
+
</table>
|
|
197
|
+
</section>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<Settings cursorStatus={data?.cursorStatus ?? 'disabled'} />
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
3
|
+
import { startRefreshLoop, fmtUSD } from '../lib/refresh.ts';
|
|
4
|
+
import { TOOL_META, TOOLS, fmtTokens, lastNDays, monthTotal, todayStr, toolTokens, useAppData } from './useAppData.ts';
|
|
5
|
+
|
|
6
|
+
export default function Popover() {
|
|
7
|
+
const data = useAppData();
|
|
8
|
+
useEffect(() => startRefreshLoop(), []);
|
|
9
|
+
|
|
10
|
+
const rollups = data?.rollups ?? [];
|
|
11
|
+
const today = rollups.find((r) => r.date === todayStr());
|
|
12
|
+
const days = lastNDays(rollups, 14);
|
|
13
|
+
const max = Math.max(1e-9, ...days.map((d) => d.totalCostUSD));
|
|
14
|
+
const refreshedMin = data ? Math.round((Date.now() - new Date(data.lastRefresh).getTime()) / 60000) : null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="popover">
|
|
18
|
+
<div className="pop-card pop-hero">
|
|
19
|
+
<div className="pop-header">
|
|
20
|
+
<div>
|
|
21
|
+
<div className="muted small">Today · {new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</div>
|
|
22
|
+
<div className="big">{fmtUSD(today?.totalCostUSD ?? 0)}</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="right">
|
|
25
|
+
<div className="muted small">This month</div>
|
|
26
|
+
<div className="mid">{fmtUSD(monthTotal(rollups))}</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div className="pop-card pop-tools">
|
|
32
|
+
{TOOLS.map((tool) => {
|
|
33
|
+
const t = today?.byTool[tool];
|
|
34
|
+
const off = tool === 'cursor' && data?.cursorStatus !== 'ok';
|
|
35
|
+
return (
|
|
36
|
+
<div className="tool-row" key={tool}>
|
|
37
|
+
<span className="dot" style={{ background: TOOL_META[tool].color }} />
|
|
38
|
+
<span className="tool-name">{TOOL_META[tool].label}</span>
|
|
39
|
+
<span className="leader" />
|
|
40
|
+
{off ? (
|
|
41
|
+
<span className="muted small">{data?.cursorStatus === 'unauthorized' ? 'reconnect in settings' : 'not connected'}</span>
|
|
42
|
+
) : (
|
|
43
|
+
<>
|
|
44
|
+
<span className="muted small">{fmtTokens(toolTokens(t))} tok</span>
|
|
45
|
+
<span className="tool-cost">{fmtUSD(t?.costUSD ?? 0)}</span>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="pop-card pop-chart">
|
|
54
|
+
<div className="muted small">Last 14 days</div>
|
|
55
|
+
<div className="bars">
|
|
56
|
+
{days.map((d) => (
|
|
57
|
+
<div className="bar-col" key={d.date} title={`${d.date}: ${fmtUSD(d.totalCostUSD)}`}>
|
|
58
|
+
{TOOLS.map((tool) => {
|
|
59
|
+
const cost = d.byTool[tool]?.costUSD ?? 0;
|
|
60
|
+
if (cost <= 0) return null;
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
key={tool}
|
|
64
|
+
style={{
|
|
65
|
+
background: TOOL_META[tool].color,
|
|
66
|
+
height: `${Math.max(2, (cost / max) * 64)}px`,
|
|
67
|
+
opacity: d.date === todayStr() ? 1 : 0.75,
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
<div className="bar-labels muted small">
|
|
76
|
+
<span>{days[0]?.date.slice(5)}</span>
|
|
77
|
+
<span>{days[days.length - 1]?.date.slice(5)}</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="pop-footer">
|
|
82
|
+
<span className="muted small">
|
|
83
|
+
{data ? (refreshedMin === 0 ? 'Updated just now' : `Updated ${refreshedMin} min ago`) : 'Scanning…'}
|
|
84
|
+
</span>
|
|
85
|
+
<button className="linkish" onClick={() => invoke('show_dashboard')}>
|
|
86
|
+
Open dashboard ↗
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
|
|
3
|
+
import { openUrl } from '@tauri-apps/plugin-opener';
|
|
4
|
+
import { getSettings, setSetting, type Theme } from '../lib/refresh.ts';
|
|
5
|
+
import type { CursorStatus } from 'tokn';
|
|
6
|
+
|
|
7
|
+
const THEMES: { value: Theme; label: string }[] = [
|
|
8
|
+
{ value: 'system', label: 'System' },
|
|
9
|
+
{ value: 'light', label: 'Light' },
|
|
10
|
+
{ value: 'dark', label: 'Dark' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export default function Settings({ cursorStatus }: { cursorStatus: CursorStatus | 'disabled' }) {
|
|
14
|
+
const [cookie, setCookie] = useState('');
|
|
15
|
+
const [saved, setSaved] = useState(false);
|
|
16
|
+
const [autostart, setAutostart] = useState(false);
|
|
17
|
+
const [theme, setTheme] = useState<Theme>('system');
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
getSettings().then((s) => {
|
|
21
|
+
setCookie(s.cursorCookie);
|
|
22
|
+
setTheme(s.theme);
|
|
23
|
+
});
|
|
24
|
+
isEnabled().then(setAutostart).catch(() => {});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const changeTheme = async (t: Theme) => {
|
|
28
|
+
setTheme(t);
|
|
29
|
+
await setSetting('theme', t);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const saveCookie = async () => {
|
|
33
|
+
await setSetting('cursorCookie', cookie.trim());
|
|
34
|
+
setSaved(true);
|
|
35
|
+
setTimeout(() => setSaved(false), 2000);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const toggleAutostart = async (on: boolean) => {
|
|
39
|
+
setAutostart(on);
|
|
40
|
+
try {
|
|
41
|
+
if (on) await enable();
|
|
42
|
+
else await disable();
|
|
43
|
+
} catch {
|
|
44
|
+
setAutostart(!on);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const statusLabel: Record<string, string> = {
|
|
49
|
+
ok: 'Connected — billed usage syncing',
|
|
50
|
+
unauthorized: 'Cookie expired or invalid — paste a fresh one',
|
|
51
|
+
error: "Couldn't reach cursor.com — will retry",
|
|
52
|
+
disabled: 'Not connected',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<section className="settings">
|
|
57
|
+
<h2>Settings</h2>
|
|
58
|
+
<div className="setting-row">
|
|
59
|
+
<div>
|
|
60
|
+
<div className="setting-name">Cursor session cookie</div>
|
|
61
|
+
<div className="muted small">
|
|
62
|
+
{statusLabel[cursorStatus]} ·{' '}
|
|
63
|
+
<a
|
|
64
|
+
className="help-link"
|
|
65
|
+
onClick={(e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
openUrl('https://cursor.com/dashboard');
|
|
68
|
+
}}
|
|
69
|
+
href="https://cursor.com/dashboard"
|
|
70
|
+
>
|
|
71
|
+
Open cursor.com/dashboard
|
|
72
|
+
</a>{' '}
|
|
73
|
+
logged in, then press ⌥⌘I → <b>Application</b> tab (behind the ≫ chevron if hidden) → left sidebar{' '}
|
|
74
|
+
<b>Storage → Cookies → https://cursor.com</b> → find <b>WorkosCursorSessionToken</b> (long value starting
|
|
75
|
+
with your user id) → double-click the Value cell and copy it
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="cookie-input">
|
|
79
|
+
<input
|
|
80
|
+
type="password"
|
|
81
|
+
placeholder="WorkosCursorSessionToken value"
|
|
82
|
+
value={cookie}
|
|
83
|
+
onChange={(e) => setCookie(e.target.value)}
|
|
84
|
+
/>
|
|
85
|
+
<button onClick={saveCookie}>{saved ? 'Saved' : 'Save'}</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="setting-row">
|
|
89
|
+
<div>
|
|
90
|
+
<div className="setting-name">Appearance</div>
|
|
91
|
+
<div className="muted small">System follows your macOS setting</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="controls">
|
|
94
|
+
{THEMES.map((t) => (
|
|
95
|
+
<button
|
|
96
|
+
key={t.value}
|
|
97
|
+
className={`seg${theme === t.value ? ' active' : ''}`}
|
|
98
|
+
onClick={() => changeTheme(t.value)}
|
|
99
|
+
>
|
|
100
|
+
{t.label}
|
|
101
|
+
</button>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="setting-row">
|
|
106
|
+
<div>
|
|
107
|
+
<div className="setting-name">Launch at login</div>
|
|
108
|
+
<div className="muted small">Start Tokn automatically in the menu bar</div>
|
|
109
|
+
</div>
|
|
110
|
+
<input type="checkbox" checked={autostart} onChange={(e) => toggleAutostart(e.target.checked)} />
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { listen } from '@tauri-apps/api/event';
|
|
3
|
+
import { getCachedData, ROLLUPS_EVENT, type AppData } from '../lib/refresh.ts';
|
|
4
|
+
import { totalTokens } from 'tokn';
|
|
5
|
+
import type { DayRollup, Tool, ToolDayTotals } from 'tokn';
|
|
6
|
+
|
|
7
|
+
export function useAppData(): AppData | null {
|
|
8
|
+
const [data, setData] = useState<AppData | null>(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let alive = true;
|
|
11
|
+
getCachedData().then((d) => {
|
|
12
|
+
if (alive && d) setData((cur) => cur ?? d);
|
|
13
|
+
});
|
|
14
|
+
const unlisten = listen<AppData>(ROLLUPS_EVENT, (e) => setData(e.payload));
|
|
15
|
+
return () => {
|
|
16
|
+
alive = false;
|
|
17
|
+
unlisten.then((f) => f());
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const TOOL_META: Record<Tool, { label: string; color: string }> = {
|
|
24
|
+
claude: { label: 'Claude Code', color: '#D97757' },
|
|
25
|
+
codex: { label: 'Codex', color: '#10A37F' },
|
|
26
|
+
cursor: { label: 'Cursor', color: '#7F77DD' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const TOOLS: Tool[] = ['claude', 'codex', 'cursor'];
|
|
30
|
+
|
|
31
|
+
export function fmtTokens(n: number): string {
|
|
32
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
33
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
34
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
|
|
35
|
+
return String(n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isoDaysAgo(n: number): string {
|
|
39
|
+
const d = new Date();
|
|
40
|
+
d.setDate(d.getDate() - n);
|
|
41
|
+
const y = d.getFullYear();
|
|
42
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
43
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
44
|
+
return `${y}-${m}-${day}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function todayStr(): string {
|
|
48
|
+
return isoDaysAgo(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** rollups for the last n days including today, zero-filled */
|
|
52
|
+
export function lastNDays(rollups: DayRollup[], n: number): DayRollup[] {
|
|
53
|
+
const byDate = new Map(rollups.map((r) => [r.date, r]));
|
|
54
|
+
const out: DayRollup[] = [];
|
|
55
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
56
|
+
const date = isoDaysAgo(i);
|
|
57
|
+
out.push(
|
|
58
|
+
byDate.get(date) ?? { date, byTool: {}, byModel: {}, byProject: {}, totalCostUSD: 0, totalTokens: 0 },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function toolTokens(t: ToolDayTotals | undefined): number {
|
|
65
|
+
return t ? totalTokens(t) : 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function monthTotal(rollups: DayRollup[]): number {
|
|
69
|
+
const prefix = todayStr().slice(0, 7);
|
|
70
|
+
return rollups.filter((r) => r.date.startsWith(prefix)).reduce((s, r) => s + r.totalCostUSD, 0);
|
|
71
|
+
}
|