usagelens 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagelens",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local dashboard for your AI coding usage across Claude Code, Codex CLI, and Cursor. Reads your machine's local data and serves an interactive dashboard on localhost. Read-only, offline, zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,5 +31,13 @@
31
31
  "cli"
32
32
  ],
33
33
  "author": "Aayush Jain",
34
- "license": "MIT"
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/aayushGofynd/usagelens.git"
38
+ },
39
+ "homepage": "https://github.com/aayushGofynd/usagelens#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/aayushGofynd/usagelens/issues"
42
+ }
35
43
  }
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ const HOME = homedir();
6
+
7
+ function readJson(path) {
8
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
9
+ }
10
+
11
+ // Decode the payload of a JWT without verifying (local, read-only, best-effort).
12
+ function jwtPayload(token) {
13
+ try {
14
+ const seg = String(token).split('.')[1];
15
+ const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
16
+ return JSON.parse(json);
17
+ } catch { return null; }
18
+ }
19
+
20
+ // Claude: oauthAccount lives in a .claude.json near the config/projects dir.
21
+ export function claudeAccount(root) {
22
+ const candidates = [
23
+ join(root, '.claude.json'),
24
+ join(dirname(root), '.claude.json'), // <root>/projects -> <root>/.claude.json
25
+ root.replace(/\/?$/, '') + '.json', // ~/.claude-work -> ~/.claude-work.json
26
+ join(HOME, '.claude.json'),
27
+ ];
28
+ for (const c of candidates) {
29
+ const j = readJson(c);
30
+ const oa = j?.oauthAccount;
31
+ if (oa?.emailAddress) return oa.emailAddress;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // Codex: email from the id_token JWT, else account_id, from auth.json.
37
+ export function codexAccount(root) {
38
+ for (const c of [join(root, 'auth.json'), join(HOME, '.codex', 'auth.json')]) {
39
+ const j = readJson(c);
40
+ if (!j) continue;
41
+ const p = jwtPayload(j.tokens?.id_token);
42
+ const email = p?.email || p?.['https://api.openai.com/profile']?.email;
43
+ if (email) return email;
44
+ const acct = j.tokens?.account_id;
45
+ if (acct) return `openai:${String(acct).slice(0, 8)}`;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ import { createRequire } from 'node:module';
51
+ const require = createRequire(import.meta.url);
52
+
53
+ // Antigravity (Gemini): display name from the VS Code state DB auth status.
54
+ export function antigravityAccount() {
55
+ const db = join(HOME, 'Library', 'Application Support', 'Antigravity', 'User', 'globalStorage', 'state.vscdb');
56
+ if (!existsSync(db)) return null;
57
+ try {
58
+ const { DatabaseSync } = require('node:sqlite');
59
+ const d = new DatabaseSync(db, { readOnly: true });
60
+ const row = d.prepare("SELECT value FROM ItemTable WHERE key='antigravityAuthStatus'").get();
61
+ d.close();
62
+ const v = row && JSON.parse(row.value);
63
+ return v?.name || v?.email || null;
64
+ } catch { return null; }
65
+ }
66
+
67
+ // Fallback label when no identity can be derived.
68
+ export function fallbackAccount(dir) { return basename(dir) || 'local'; }
@@ -1,137 +1,195 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { homedir } from 'node:os';
3
4
  import { basename, join } from 'node:path';
4
5
  import { costOf } from './pricing.mjs';
6
+ import { antigravityAccount, claudeAccount, codexAccount, fallbackAccount } from './accounts.mjs';
5
7
 
6
8
  const HOME = homedir();
9
+ const require = createRequire(import.meta.url);
10
+ const expand = (p) => (p.startsWith('~') ? join(HOME, p.slice(1)) : p);
7
11
 
8
- function walk(dir) {
12
+ function walk(dir, ext) {
9
13
  if (!existsSync(dir)) return [];
10
14
  const out = [];
11
15
  for (const name of readdirSync(dir)) {
12
16
  let p = join(dir, name), st;
13
17
  try { st = statSync(p); } catch { continue; }
14
- if (st.isDirectory()) out.push(...walk(p));
15
- else if (name.endsWith('.jsonl')) out.push(p);
18
+ if (st.isDirectory()) out.push(...walk(p, ext));
19
+ else if (!ext || name.endsWith(ext)) out.push(p);
16
20
  }
17
21
  return out;
18
22
  }
19
23
 
20
24
  // ---------- Claude ----------
21
- export function loadClaude() {
22
- const root = join(HOME, '.claude', 'projects');
23
- if (!existsSync(root)) return { events: [], commits: [], status: { tool: 'claude', available: false, note: 'no ~/.claude/projects' } };
25
+ export function loadClaude(dir, account) {
26
+ const root = dir ?? join(HOME, '.claude', 'projects');
27
+ if (!existsSync(root)) return { events: [], commits: [], activity: [], status: { tool: 'claude', account, available: false, note: 'no projects dir' } };
24
28
  const events = [];
25
- for (const f of walk(root)) {
26
- let text;
27
- try { text = readFileSync(f, 'utf8'); } catch { continue; }
29
+ for (const f of walk(root, '.jsonl')) {
30
+ let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
28
31
  for (const line of text.split('\n')) {
29
32
  if (!line.trim()) continue;
30
- let row;
31
- try { row = JSON.parse(line); } catch { continue; }
33
+ let row; try { row = JSON.parse(line); } catch { continue; }
32
34
  if (row.type !== 'assistant') continue;
33
35
  const m = row.message, u = m?.usage;
34
36
  if (!u || !m?.model) continue;
35
37
  const cwd = row.cwd ?? 'unknown';
36
38
  const t = {
37
- inputTokens: u.input_tokens ?? 0,
38
- outputTokens: u.output_tokens ?? 0,
39
- cacheReadTokens: u.cache_read_input_tokens ?? 0,
40
- cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
39
+ inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0,
40
+ cacheReadTokens: u.cache_read_input_tokens ?? 0, cacheCreateTokens: u.cache_creation_input_tokens ?? 0,
41
41
  };
42
42
  events.push({
43
- tool: 'claude', model: m.model, project: basename(cwd), projectPath: cwd,
43
+ tool: 'claude', account, model: m.model, project: basename(cwd), projectPath: cwd,
44
44
  gitBranch: row.gitBranch || undefined, timestamp: row.timestamp,
45
45
  sessionId: row.sessionId ?? basename(f, '.jsonl'), ...t, costUsd: costOf(m.model, t),
46
46
  });
47
47
  }
48
48
  }
49
- const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
50
- return { events, commits: [], status: { tool: 'claude', available: true, lastActivity: last, count: events.length } };
49
+ return { events, commits: [], activity: [], status: statusOf('claude', account, events) };
51
50
  }
52
51
 
53
52
  // ---------- Codex ----------
54
- export function loadCodex() {
55
- const base = join(HOME, '.codex');
56
- const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r)));
57
- if (files.length === 0) return { events: [], commits: [], status: { tool: 'codex', available: false, note: 'no ~/.codex sessions' } };
53
+ export function loadCodex(dir, account) {
54
+ const base = dir ?? join(HOME, '.codex');
55
+ const files = ['sessions', 'archived_sessions'].flatMap((r) => walk(join(base, r), '.jsonl'));
56
+ if (files.length === 0) return { events: [], commits: [], activity: [], status: { tool: 'codex', account, available: false, note: 'no sessions' } };
58
57
  const events = [];
59
58
  for (const f of files) {
60
- let text;
61
- try { text = readFileSync(f, 'utf8'); } catch { continue; }
59
+ let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
62
60
  let model = 'gpt-5', cwd = 'unknown', sessionId = basename(f, '.jsonl');
63
61
  for (const line of text.split('\n')) {
64
62
  if (!line.trim()) continue;
65
- let row;
66
- try { row = JSON.parse(line); } catch { continue; }
63
+ let row; try { row = JSON.parse(line); } catch { continue; }
67
64
  const p = row.payload ?? row;
68
- // Codex sessions nest the real event type inside event_msg/response_item payloads.
69
65
  const kind = (row.type === 'event_msg' || row.type === 'response_item') ? (p.type ?? row.type) : row.type;
70
66
  if (kind === 'session_meta') { cwd = p.cwd ?? cwd; sessionId = p.id ?? sessionId; }
71
67
  else if (kind === 'turn_context') { if (p.model) model = p.model; if (p.cwd) cwd = p.cwd; }
72
68
  else if (kind === 'token_count') {
73
- const last = p.info?.last_token_usage;
74
- if (!last) continue;
69
+ const last = p.info?.last_token_usage; if (!last) continue;
75
70
  const cached = last.cached_input_tokens ?? 0;
76
71
  const t = {
77
- inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached),
78
- outputTokens: last.output_tokens ?? 0,
72
+ inputTokens: Math.max(0, (last.input_tokens ?? 0) - cached), outputTokens: last.output_tokens ?? 0,
79
73
  cacheReadTokens: cached, cacheCreateTokens: 0,
80
74
  };
81
75
  if (t.inputTokens + t.outputTokens + t.cacheReadTokens === 0) continue;
82
76
  events.push({
83
- tool: 'codex', model, project: basename(cwd), projectPath: cwd,
77
+ tool: 'codex', account, model, project: basename(cwd), projectPath: cwd,
84
78
  timestamp: row.timestamp, sessionId, ...t, costUsd: costOf(model, t),
85
79
  });
86
80
  }
87
81
  }
88
82
  }
89
- const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
90
- return { events, commits: [], status: { tool: 'codex', available: true, lastActivity: last, count: events.length } };
83
+ return { events, commits: [], activity: [], status: statusOf('codex', account, events) };
91
84
  }
92
85
 
93
86
  // ---------- Cursor ----------
94
- export function loadCursor() {
95
- const path = join(HOME, '.cursor', 'ai-tracking', 'ai-code-tracking.db');
96
- if (!existsSync(path)) return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'no cursor ai-tracking db' } };
87
+ export function loadCursor(dir, account) {
88
+ const path = join(dir ?? join(HOME, '.cursor'), 'ai-tracking', 'ai-code-tracking.db');
89
+ if (!existsSync(path)) return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'no ai-tracking db' } };
97
90
  const commits = [];
98
91
  try {
99
92
  const { DatabaseSync } = require('node:sqlite');
100
93
  const db = new DatabaseSync(path, { readOnly: true });
101
- const rows = db.prepare(
102
- 'SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits'
103
- ).all();
94
+ const rows = db.prepare('SELECT commitHash, branchName, scoredAt, linesAdded, v1AiPercentage, v2AiPercentage FROM scored_commits').all();
104
95
  for (const r of rows) {
105
96
  const total = r.linesAdded ?? 0;
106
97
  const aiPct = Number(r.v2AiPercentage ?? r.v1AiPercentage ?? 0) || 0;
107
98
  const aiLines = Math.round((total * aiPct) / 100);
108
99
  commits.push({
109
- tool: 'cursor', timestamp: new Date(Number(r.scoredAt)).toISOString(),
100
+ tool: 'cursor', account, timestamp: new Date(Number(r.scoredAt)).toISOString(),
110
101
  commitHash: r.commitHash, branchName: r.branchName ?? 'unknown',
111
102
  totalLines: total, aiLines, humanLines: Math.max(0, total - aiLines), aiPct,
112
103
  });
113
104
  }
114
105
  db.close();
115
106
  } catch (e) {
116
- return { events: [], commits: [], status: { tool: 'cursor', available: false, note: 'cursor db unreadable: ' + e.message } };
107
+ return { events: [], commits: [], activity: [], status: { tool: 'cursor', account, available: false, note: 'db unreadable: ' + e.message } };
117
108
  }
118
109
  const last = commits.reduce((a, c) => (!a || c.timestamp > a ? c.timestamp : a), undefined);
119
- return { events: [], commits, status: { tool: 'cursor', available: true, lastActivity: last, count: commits.length } };
110
+ return { events: [], commits, activity: [], status: { tool: 'cursor', account, available: true, lastActivity: last, count: commits.length, kind: 'authorship' } };
120
111
  }
121
112
 
122
- // require shim for ESM (node:sqlite import)
123
- import { createRequire } from 'node:module';
124
- const require = createRequire(import.meta.url);
113
+ // ---------- Antigravity (Gemini) activity only, no tokens ----------
114
+ export function loadAntigravity(dir, account) {
115
+ const base = dir ?? join(HOME, '.gemini', 'antigravity');
116
+ if (!existsSync(base)) return { events: [], commits: [], activity: [], status: { tool: 'antigravity', account, available: false, note: 'not installed' } };
117
+ const activity = [];
118
+ // conversations = sessions
119
+ for (const f of walk(join(base, 'conversations'), '.pb')) {
120
+ let ts; try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; }
121
+ activity.push({ tool: 'antigravity', account, kind: 'session', project: 'antigravity', timestamp: ts });
122
+ }
123
+ // code_tracker = AI-touched files, project = workspace dir (minus trailing hash)
124
+ for (const root of ['active', 'history']) {
125
+ const rd = join(base, 'code_tracker', root);
126
+ if (!existsSync(rd)) continue;
127
+ for (const ws of readdirSync(rd)) {
128
+ const wsDir = join(rd, ws);
129
+ let st; try { st = statSync(wsDir); } catch { continue; }
130
+ if (!st.isDirectory()) continue;
131
+ const project = ws.replace(/_[0-9a-f]{40}$/, '') || 'antigravity';
132
+ for (const f of walk(wsDir)) {
133
+ const m = basename(f).match(/_(\d{14})-/);
134
+ let ts;
135
+ if (m) { const d = m[1]; ts = `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}T${d.slice(8, 10)}:${d.slice(10, 12)}:${d.slice(12, 14)}`; }
136
+ else { try { ts = new Date(statSync(f).mtimeMs).toISOString(); } catch { continue; } }
137
+ activity.push({ tool: 'antigravity', account, kind: 'file', project, timestamp: ts });
138
+ }
139
+ }
140
+ }
141
+ const last = activity.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
142
+ return { events: [], commits: [], activity, status: { tool: 'antigravity', account, available: activity.length > 0, lastActivity: last, count: activity.length, kind: 'activity' } };
143
+ }
144
+
145
+ function statusOf(tool, account, events) {
146
+ const last = events.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined);
147
+ return { tool, account, available: events.length > 0, lastActivity: last, count: events.length, kind: 'tokens' };
148
+ }
149
+
150
+ // ---------- source composition ----------
151
+ function defaultSources() {
152
+ return [
153
+ { tool: 'claude', dir: join(HOME, '.claude', 'projects'), account: claudeAccount(join(HOME, '.claude', 'projects')) },
154
+ { tool: 'codex', dir: join(HOME, '.codex'), account: codexAccount(join(HOME, '.codex')) },
155
+ { tool: 'cursor', dir: join(HOME, '.cursor'), account: 'cursor (local)' },
156
+ { tool: 'antigravity', dir: join(HOME, '.gemini', 'antigravity'), account: antigravityAccount() },
157
+ ];
158
+ }
159
+
160
+ function configuredSources() {
161
+ const cfg = join(HOME, '.usagelens.json');
162
+ if (!existsSync(cfg)) return null;
163
+ try {
164
+ const j = JSON.parse(readFileSync(cfg, 'utf8'));
165
+ if (!Array.isArray(j.sources) || j.sources.length === 0) return null;
166
+ return j.sources.map((s) => ({ tool: s.tool, dir: expand(s.dir ?? ''), account: s.label ?? s.account ?? null }));
167
+ } catch { return null; }
168
+ }
169
+
170
+ const LOADERS = { claude: loadClaude, codex: loadCodex, cursor: loadCursor, antigravity: loadAntigravity };
125
171
 
126
172
  export function loadAll() {
127
- const events = [], commits = [], statuses = [];
128
- for (const fn of [loadClaude, loadCodex, loadCursor]) {
129
- try { const r = fn(); events.push(...r.events); commits.push(...r.commits); statuses.push(r.status); }
130
- catch (e) { statuses.push({ tool: fn.name.replace('load', '').toLowerCase(), available: false, note: e.message }); }
173
+ const sources = configuredSources() ?? defaultSources();
174
+ const events = [], commits = [], activity = [], statuses = [];
175
+ const accounts = new Set();
176
+ for (const s of sources) {
177
+ const loader = LOADERS[s.tool];
178
+ if (!loader) { statuses.push({ tool: s.tool, available: false, note: 'unknown tool' }); continue; }
179
+ const account = s.account || fallbackAccount(s.dir);
180
+ try {
181
+ const r = loader(s.dir, account);
182
+ events.push(...r.events); commits.push(...r.commits); activity.push(...(r.activity ?? []));
183
+ statuses.push(r.status);
184
+ if (r.status.available) accounts.add(account);
185
+ } catch (e) { statuses.push({ tool: s.tool, account, available: false, note: e.message }); }
131
186
  }
132
- // detect-only tools with no parseable usage
133
- for (const [tool, rel] of [['gemini', '.gemini'], ['copilot', '.copilot'], ['ollama', '.ollama']]) {
134
- if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note: 'detected, no usage metrics exposed' });
187
+ // tools present on disk but exposing no usable local usage
188
+ for (const [tool, rel, note] of [
189
+ ['copilot', '.copilot', 'no local usage (editor/cloud only)'],
190
+ ['ollama', '.ollama', 'no logged inference calls (server pings only)'],
191
+ ]) {
192
+ if (existsSync(join(HOME, rel))) statuses.push({ tool, available: false, note });
135
193
  }
136
- return { events, commits, statuses };
194
+ return { events, commits, activity, statuses, accounts: [...accounts].sort() };
137
195
  }
@@ -141,11 +141,30 @@ function authorship(commits) {
141
141
  };
142
142
  }
143
143
 
144
- export function buildPayload(ds, range, now) {
144
+ function activitySummary(activity) {
145
+ const byProject = new Map();
146
+ const byDay = new Set();
147
+ let sessions = 0, files = 0;
148
+ for (const a of activity) {
149
+ if (a.kind === 'session') sessions += 1; else files += 1;
150
+ byProject.set(a.project, (byProject.get(a.project) ?? 0) + 1);
151
+ byDay.add(localDayKey(a.timestamp));
152
+ }
153
+ return {
154
+ total: activity.length, sessions, files, activeDays: byDay.size,
155
+ byProject: [...byProject.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 8),
156
+ };
157
+ }
158
+
159
+ export function buildPayload(ds, range, now, account) {
145
160
  const win = rangeWindow(range, now), prior = priorWindow(range, now);
146
- const cur = ds.events.filter((e) => inWin(e.timestamp, win));
147
- const prev = ds.events.filter((e) => inWin(e.timestamp, prior));
148
- const commits = ds.commits.filter((c) => inWin(c.timestamp, win));
161
+ const pick = (arr) => (account && account !== 'all' ? arr.filter((x) => x.account === account) : arr);
162
+ const evAll = pick(ds.events), coAll = pick(ds.commits), acAll = pick(ds.activity ?? []);
163
+ const cur = evAll.filter((e) => inWin(e.timestamp, win));
164
+ const prev = evAll.filter((e) => inWin(e.timestamp, prior));
165
+ const commits = coAll.filter((c) => inWin(c.timestamp, win));
166
+ const activity = acAll.filter((a) => inWin(a.timestamp, win));
167
+ const lastAll = [...cur.map((e) => e.timestamp), ...commits.map((c) => c.timestamp), ...activity.map((a) => a.timestamp)];
149
168
  return {
150
169
  summary: summarize(cur, prev),
151
170
  breakdowns: { byTool: group(cur, (e) => e.tool), byModel: group(cur, (e) => e.model), byProject: group(cur, (e) => e.project, 12) },
@@ -153,7 +172,9 @@ export function buildPayload(ds, range, now) {
153
172
  heatmap: heatmap(cur),
154
173
  sessions: sessionShape(cur),
155
174
  authorship: authorship(commits),
175
+ activity: activitySummary(activity),
156
176
  tools: ds.statuses,
157
- meta: { range, generatedAt: now.toISOString(), lastActivity: cur.reduce((a, e) => (!a || e.timestamp > a ? e.timestamp : a), undefined) },
177
+ accounts: ds.accounts ?? [],
178
+ meta: { range, account: account ?? 'all', generatedAt: now.toISOString(), lastActivity: lastAll.reduce((a, t) => (!a || t > a ? t : a), undefined) },
158
179
  };
159
180
  }
package/src/public/app.js CHANGED
@@ -217,9 +217,28 @@ function render(d) {
217
217
  ]);
218
218
  app.append(el('div', { class: 'grid' }, [panel('CODE AUTHORSHIP · CURSOR', 'weekly AI vs human lines', authBody)]));
219
219
 
220
+ // activity (Antigravity / Gemini) — count-only signal, no tokens
221
+ if (d.activity && d.activity.total > 0) {
222
+ const maxA = d.activity.byProject[0]?.count || 1;
223
+ const actBody = el('div', {}, [
224
+ el('div', { class: 'statPair', style: 'margin-bottom:10px' }, [
225
+ el('div', {}, [el('div', { class: 'k' }, 'SESSIONS'), el('div', { class: 'v' }, fmtInt(d.activity.sessions))]),
226
+ el('div', {}, [el('div', { class: 'k' }, 'AI-TOUCHED FILES'), el('div', { class: 'v' }, fmtInt(d.activity.files))]),
227
+ el('div', {}, [el('div', { class: 'k' }, 'ACTIVE DAYS'), el('div', { class: 'v' }, fmtInt(d.activity.activeDays))]),
228
+ ]),
229
+ ...d.activity.byProject.map((p) => barRow(p.key, `${fmtInt(p.count)}`, (p.count / maxA) * 100, 'var(--c5)')),
230
+ ]);
231
+ app.append(el('div', { class: 'grid' }, [panel('ACTIVITY · ANTIGRAVITY (GEMINI)', 'sessions & AI-touched files · no token data', actBody)]));
232
+ }
233
+
220
234
  // tool chips
221
- const chips = el('div', { class: 'tools' }, d.tools.map((t) =>
222
- el('div', { class: 'chip ' + (t.available ? 'on' : ''), html: `<span class="d" style="background:${t.available ? 'var(--good)' : 'var(--line)'};width:8px;height:8px;border-radius:50%;display:inline-block"></span><b>${esc(t.tool)}</b> ${t.available ? (t.count != null ? fmtInt(t.count) + ' records' : 'active') : esc(t.note || 'no data')}` })));
235
+ const kindLabel = { tokens: 'tokens', authorship: 'code lines', activity: 'activity' };
236
+ const chips = el('div', { class: 'tools' }, d.tools.map((t) => {
237
+ const detail = t.available
238
+ ? `${t.count != null ? fmtInt(t.count) + ' ' + (kindLabel[t.kind] || 'records') : 'active'}${t.account ? ' · ' + esc(t.account) : ''}`
239
+ : esc(t.note || 'no data');
240
+ return el('div', { class: 'chip ' + (t.available ? 'on' : ''), html: `<span class="d" style="background:${t.available ? 'var(--good)' : 'var(--line)'};width:8px;height:8px;border-radius:50%;display:inline-block"></span><b>${esc(t.tool)}</b> ${detail}` });
241
+ }));
223
242
  app.append(el('div', {}, [el('div', { class: 'clabel', style: 'margin-top:8px' }, 'TOOLS DETECTED'), chips]));
224
243
  app.append(el('div', { class: 'foot' }, `Generated ${new Date(d.meta.generatedAt).toLocaleString()} · read-only from ~/.claude, ~/.codex, ~/.cursor · cost is estimated API-equivalent, not a bill`));
225
244
  }
@@ -227,7 +246,7 @@ function render(d) {
227
246
  let range = '30d';
228
247
  async function load() {
229
248
  try {
230
- const r = await fetch('/api/dashboard?range=' + range);
249
+ const r = await fetch(`/api/dashboard?range=${range}&account=all`);
231
250
  render(await r.json());
232
251
  } catch (e) { $('#app').innerHTML = '<div class="loading">Failed: ' + e.message + '</div>'; }
233
252
  }
@@ -22,6 +22,8 @@
22
22
  h1.hero b{color:var(--accent);font-weight:600;font-variant-numeric:tabular-nums}
23
23
  .heroSub{color:var(--sub);font-size:14px;margin:0}
24
24
  .heroSub b{color:var(--ink);font-weight:600}
25
+ .controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
26
+ #account{font-family:var(--mono);font-size:13px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:7px 12px;cursor:pointer;max-width:280px}
25
27
  .toggle{display:inline-flex;gap:2px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:3px}
26
28
  .toggle button{border:0;background:transparent;color:var(--sub);font-weight:600;font-size:13px;
27
29
  padding:6px 14px;border-radius:7px;cursor:pointer;font-family:var(--mono)}
package/src/server.mjs CHANGED
@@ -29,10 +29,11 @@ function parseArgs(argv) {
29
29
 
30
30
  const { port, open } = parseArgs(process.argv.slice(2));
31
31
 
32
- process.stdout.write('usagelens · scanning ~/.claude, ~/.codex, ~/.cursor …\n');
32
+ process.stdout.write('usagelens · scanning Claude, Codex, Cursor, Antigravity …\n');
33
33
  const t0 = Date.now();
34
34
  const dataset = loadAll();
35
- process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits in ${Date.now() - t0}ms\n`);
35
+ process.stdout.write(`usagelens · loaded ${dataset.events.length} usage events + ${dataset.commits.length} commits + ${dataset.activity.length} activity in ${Date.now() - t0}ms\n`);
36
+ process.stdout.write(`usagelens · accounts: ${dataset.accounts.join(', ') || 'none'}\n`);
36
37
  for (const s of dataset.statuses) process.stdout.write(` ${s.available ? '✓' : '·'} ${s.tool}${s.count != null ? ` (${s.count})` : ''}${s.note ? ' — ' + s.note : ''}\n`);
37
38
 
38
39
  const server = createServer((req, res) => {
@@ -40,7 +41,8 @@ const server = createServer((req, res) => {
40
41
  if (url.pathname === '/api/dashboard') {
41
42
  const rq = url.searchParams.get('range');
42
43
  const range = ['7d', '30d', '90d'].includes(rq) ? rq : '30d';
43
- const body = JSON.stringify(buildPayload(dataset, range, new Date()));
44
+ const account = url.searchParams.get('account') || 'all';
45
+ const body = JSON.stringify(buildPayload(dataset, range, new Date(), account));
44
46
  res.writeHead(200, { 'content-type': 'application/json' }).end(body);
45
47
  return;
46
48
  }