ai-usage-analyzer 0.2.1 → 0.3.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/bin/ai-usage.js CHANGED
@@ -7,7 +7,8 @@ import { loadAll, dateRange } from '../src/loaders.js';
7
7
  import { overall } from '../src/aggregate.js';
8
8
  import {
9
9
  renderHeader, renderDetections, renderOverview,
10
- renderPerProject, renderPerMonth, renderPerWeek,
10
+ renderPerProject, renderPerTool, renderPerToolPerMonth,
11
+ renderPerMonth, renderPerWeek,
11
12
  renderTopSessions, renderNotes,
12
13
  } from '../src/render.js';
13
14
  import { renderMarkdown } from '../src/markdown.js';
@@ -23,6 +24,12 @@ const topN = (() => {
23
24
  const v = parseInt(args[i + 1], 10);
24
25
  return Number.isFinite(v) && v > 0 ? v : 5;
25
26
  })();
27
+ const yearFilter = (() => {
28
+ const i = args.indexOf('--year');
29
+ if (i < 0) return null;
30
+ const v = parseInt(args[i + 1], 10);
31
+ return Number.isFinite(v) && v > 1970 && v < 3000 ? v : null;
32
+ })();
26
33
 
27
34
  if (showHelp) {
28
35
  console.log(`
@@ -36,9 +43,11 @@ Options:
36
43
  --json Output machine-readable JSON instead of TUI
37
44
  --markdown, --md Output as a Markdown report (GitHub-flavored tables)
38
45
  --top N Show top N heaviest sessions (default: 5)
46
+ --year YYYY Filter records to a single year (e.g. --year 2026)
39
47
 
40
48
  Examples:
41
49
  ai-usage # default TUI
50
+ ai-usage --year 2026 # TUI, only 2026 sessions
42
51
  ai-usage --json | jq .summary # pipe to jq
43
52
  ai-usage --md > report.md # save as markdown
44
53
  ai-usage --top 20 # show top 20 sessions
@@ -49,7 +58,7 @@ Environment overrides (per-tool data path):
49
58
  AI_USAGE_PATHS_JSON='{"codex":"/custom/path",...}'
50
59
 
51
60
  Supported tools:
52
- • Claude Code — ~/.claude/projects (presence only)
61
+ • Claude Code — ~/.claude/projects (tokens from per-message usage)
53
62
  • Codex — ~/.codex/sessions (tokens from token_count events)
54
63
  • OpenCode — ~/.local/share/opencode/opencode.db (tokens + cost)
55
64
  • MimoCode — ~/.local/share/mimocode/mimocode.db (tokens + cost)
@@ -68,7 +77,14 @@ if (jsonOut && mdOut) {
68
77
  async function main() {
69
78
  const t0 = Date.now();
70
79
  const detections = detectAll();
71
- const { records, errors } = await loadAll(detections);
80
+ const { records: allRecords, errors } = await loadAll(detections);
81
+
82
+ // Apply --year filter before any aggregation so dateRange and totals
83
+ // reflect the filtered set.
84
+ const records = yearFilter !== null
85
+ ? allRecords.filter(r => r.month && r.month.startsWith(`${yearFilter}-`))
86
+ : allRecords;
87
+
72
88
  const range = dateRange(records);
73
89
  const tot = overall(records);
74
90
 
@@ -78,6 +94,7 @@ async function main() {
78
94
  summary: tot,
79
95
  dateRange: range,
80
96
  sessions: records,
97
+ filter: yearFilter !== null ? { year: yearFilter } : null,
81
98
  errors,
82
99
  generatedAt: new Date().toISOString(),
83
100
  durationMs: Date.now() - t0,
@@ -109,6 +126,8 @@ async function main() {
109
126
  sections.push(
110
127
  renderOverview(records, detections),
111
128
  renderPerProject(records),
129
+ renderPerTool(records),
130
+ renderPerToolPerMonth(records),
112
131
  renderPerMonth(records),
113
132
  renderPerWeek(records),
114
133
  renderTopSessions(records, topN),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-usage-analyzer",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "TUI analyzer for local AI coding agent token usage. Auto-detects Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, and Gemini.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
package/src/aggregate.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  const MONTH_NAMES = {
4
4
  '01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr',
5
- '05': 'Mei', '06': 'Jun', '07': 'Jul', '08': 'Agu',
6
- '09': 'Sep', '10': 'Okt', '11': 'Nov', '12': 'Des',
5
+ '05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug',
6
+ '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Dec',
7
7
  };
8
8
 
9
9
  function sum(arr, key) {
@@ -38,13 +38,23 @@ function summarize(records) {
38
38
  };
39
39
  }
40
40
 
41
+ function toolBreakdown(records) {
42
+ const byTool = {};
43
+ for (const r of records) {
44
+ byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
45
+ }
46
+ return byTool;
47
+ }
48
+
41
49
  export function perProject(records) {
42
- const m = groupBy(records, r => `${r.tool}\u0001${r.project}`);
50
+ // One row per project tool mix is shown via the stacked bar / byTool,
51
+ // not as a separate column. Detailed per-tool-per-project breakdown
52
+ // is no longer surfaced here; the "Per Tool per Month" section is the
53
+ // place to see per-tool data over time.
54
+ const m = groupBy(records, r => r.project);
43
55
  const out = [];
44
- for (const [k, arr] of m) {
45
- const [tool, project] = k.split('\u0001');
46
- const s = summarize(arr);
47
- out.push({ tool, project, ...s });
56
+ for (const [project, arr] of m) {
57
+ out.push({ project, ...summarize(arr), byTool: toolBreakdown(arr) });
48
58
  }
49
59
  return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
50
60
  }
@@ -53,11 +63,7 @@ export function perMonth(records) {
53
63
  const m = groupBy(records, r => r.month);
54
64
  const out = [];
55
65
  for (const [month, arr] of m) {
56
- const byTool = {};
57
- for (const r of arr) {
58
- byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
59
- }
60
- out.push({ month, ...summarize(arr), byTool });
66
+ out.push({ month, ...summarize(arr), byTool: toolBreakdown(arr) });
61
67
  }
62
68
  return out.sort((a, b) => a.month.localeCompare(b.month));
63
69
  }
@@ -66,11 +72,7 @@ export function perWeek(records) {
66
72
  const m = groupBy(records, r => r.week);
67
73
  const out = [];
68
74
  for (const [week, arr] of m) {
69
- const byTool = {};
70
- for (const r of arr) {
71
- byTool[r.tool] = (byTool[r.tool] || 0) + r.tokensTotal;
72
- }
73
- out.push({ week, ...summarize(arr), byTool });
75
+ out.push({ week, ...summarize(arr), byTool: toolBreakdown(arr) });
74
76
  }
75
77
  return out.sort((a, b) => a.week.localeCompare(b.week));
76
78
  }
@@ -84,6 +86,19 @@ export function perTool(records) {
84
86
  return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
85
87
  }
86
88
 
89
+ export function perToolPerMonth(records) {
90
+ // Cross-tab: one row per (tool, month). Lets you see how a single tool's
91
+ // usage is distributed across months — and avoids the hardcoded OC/CX/MM
92
+ // column problem in the per-month table.
93
+ const m = groupBy(records, r => `${r.tool}\u0001${r.month}`);
94
+ const out = [];
95
+ for (const [k, arr] of m) {
96
+ const [tool, month] = k.split('\u0001');
97
+ out.push({ tool, month, ...summarize(arr) });
98
+ }
99
+ return out.sort((a, b) => b.tokensTotal - a.tokensTotal);
100
+ }
101
+
87
102
  export function overall(records) {
88
103
  return summarize(records);
89
104
  }
package/src/detectors.js CHANGED
@@ -1,33 +1,14 @@
1
1
  // Auto-detect AI coding agent data directories.
2
2
  //
3
- // Strategy:
4
- // 1. Honor $AI_USAGE_PATHS_JSON if set (JSON map of { toolKey: "/abs/path" })
5
- // 2. Honor per-tool env var (e.g. $CLAUDE_HOME, $CODEX_HOME, $OPENCODE_HOME)
6
- // 3. Probe well-known locations per platform (mac/linux) relative to $HOME
7
- // 4. Return a status for each tool: 'present' | 'absent' | 'disabled'
8
- //
9
- // Tools that share the opencode SQLite schema are auto-registered.
10
-
11
- import { existsSync, statSync, readdirSync } from 'node:fs';
12
- import { join, isAbsolute } from 'node:path';
13
- import { homedir, platform } from 'node:os';
14
- import { env, exitCode } from 'node:process';
15
- import { createRequire } from 'node:module';
16
- const require = createRequire(import.meta.url);
17
-
18
- const HOME = homedir();
19
- const OS = platform(); // 'darwin' | 'linux' | 'win32'
3
+ // All tool configuration (paths, env vars, kinds, display metadata) lives
4
+ // in src/tools.js. This file only orchestrates: probe paths, apply user
5
+ // overrides, and project each tool's metadata into the detection result.
20
6
 
21
- // macOS Application Support helper
22
- const APP_SUPPORT = OS === 'darwin'
23
- ? join(HOME, 'Library', 'Application Support')
24
- : process.env.XDG_DATA_HOME
25
- ? join(process.env.XDG_DATA_HOME, '..') // XDG_DATA_HOME/../ = ~/.local/share
26
- : join(HOME, '.local', 'share');
7
+ import { existsSync } from 'node:fs';
8
+ import { isAbsolute } from 'node:path';
9
+ import { env } from 'node:process';
10
+ import { TOOLS, TOOL_ORDER } from './tools.js';
27
11
 
28
- const CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
29
-
30
- // Probe = array of candidate paths; first one that exists wins.
31
12
  function firstExisting(paths) {
32
13
  for (const p of paths) {
33
14
  if (p && existsSync(p)) return p;
@@ -35,236 +16,17 @@ function firstExisting(paths) {
35
16
  return null;
36
17
  }
37
18
 
38
- function isFile(p) {
39
- try { return statSync(p).isFile(); } catch { return false; }
40
- }
41
- function isDir(p) {
42
- try { return statSync(p).isDirectory(); } catch { return false; }
19
+ function safeParseJSON(s) {
20
+ try { return JSON.parse(s); } catch { return {}; }
43
21
  }
44
22
 
45
- // ---------------------------------------------------------------------------
46
- // Detector definitions. Each entry returns:
47
- // { key, name, kind, status, path, count, details }
48
- // ---------------------------------------------------------------------------
49
-
50
- const DETECTORS = [
51
- // -----------------------------------------------------------------------
52
- // Claude Code
53
- // -----------------------------------------------------------------------
54
- {
55
- key: 'claude',
56
- name: 'Claude Code',
57
- kind: 'jsonl',
58
- envVar: 'CLAUDE_HOME',
59
- candidatePaths: () => [
60
- env.CLAUDE_HOME
61
- ? join(env.CLAUDE_HOME, 'projects')
62
- : join(HOME, '.claude', 'projects'),
63
- ],
64
- count: (p) => {
65
- if (!p) return 0;
66
- let n = 0;
67
- function walk(dir) {
68
- try {
69
- for (const e of readdirSync(dir, { withFileTypes: true })) {
70
- const full = join(dir, e.name);
71
- if (e.isDirectory()) walk(full);
72
- else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
73
- }
74
- } catch {}
75
- }
76
- walk(p);
77
- return n;
78
- },
79
- hasTokens: false, // transcripts only contain text, no token counts
80
- description: '~/.claude/projects/*/<UUID>.jsonl (no token data stored locally)',
81
- },
82
-
83
- // -----------------------------------------------------------------------
84
- // Codex
85
- // -----------------------------------------------------------------------
86
- {
87
- key: 'codex',
88
- name: 'Codex',
89
- kind: 'jsonl-rollout',
90
- envVar: 'CODEX_HOME',
91
- candidatePaths: () => [
92
- env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
93
- ],
94
- count: (p) => {
95
- if (!p) return 0;
96
- let n = 0;
97
- function walk(dir) {
98
- try {
99
- for (const e of readdirSync(dir, { withFileTypes: true })) {
100
- const full = join(dir, e.name);
101
- if (e.isDirectory()) walk(full);
102
- else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) n++;
103
- }
104
- } catch {}
105
- }
106
- walk(p);
107
- return n;
108
- },
109
- hasTokens: true,
110
- description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
111
- },
112
-
113
- // -----------------------------------------------------------------------
114
- // OpenCode
115
- // -----------------------------------------------------------------------
116
- {
117
- key: 'opencode',
118
- name: 'OpenCode',
119
- kind: 'sqlite',
120
- envVar: 'OPENCODE_HOME',
121
- candidatePaths: () => [
122
- env.OPENCODE_HOME
123
- ? join(env.OPENCODE_HOME, 'opencode.db')
124
- : join(HOME, '.local', 'share', 'opencode', 'opencode.db'),
125
- ],
126
- count: (p) => {
127
- if (!p) return 0;
128
- try {
129
- const { DatabaseSync } = require('node:sqlite');
130
- const db = new DatabaseSync(p, { readOnly: true });
131
- return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
132
- } catch { return 0; }
133
- },
134
- hasTokens: true,
135
- description: '~/.local/share/opencode/opencode.db (tokens + cost)',
136
- },
137
-
138
- // -----------------------------------------------------------------------
139
- // MimoCode (same schema as OpenCode)
140
- // -----------------------------------------------------------------------
141
- {
142
- key: 'mimocode',
143
- name: 'MimoCode',
144
- kind: 'sqlite',
145
- envVar: 'MIMOCODE_HOME',
146
- candidatePaths: () => [
147
- env.MIMOCODE_HOME
148
- ? join(env.MIMOCODE_HOME, 'mimocode.db')
149
- : join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
150
- ],
151
- count: (p) => {
152
- if (!p) return 0;
153
- try {
154
- const { DatabaseSync } = require('node:sqlite');
155
- const db = new DatabaseSync(p, { readOnly: true });
156
- return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
157
- } catch { return 0; }
158
- },
159
- hasTokens: true,
160
- description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
161
- },
162
-
163
- // -----------------------------------------------------------------------
164
- // GitHub Copilot CLI
165
- // -----------------------------------------------------------------------
166
- {
167
- key: 'copilot',
168
- name: 'GitHub Copilot',
169
- kind: 'jsonl-events',
170
- envVar: 'COPILOT_HOME',
171
- candidatePaths: () => {
172
- const base = env.COPILOT_HOME || join(HOME, '.copilot');
173
- return [
174
- join(base, 'session-state'),
175
- base,
176
- ];
177
- },
178
- count: (p) => {
179
- if (!p) return 0;
180
- let n = 0;
181
- function walk(dir) {
182
- try {
183
- for (const e of readdirSync(dir, { withFileTypes: true })) {
184
- const full = join(dir, e.name);
185
- if (e.isDirectory()) walk(full);
186
- else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
187
- }
188
- } catch {}
189
- }
190
- walk(p);
191
- return n;
192
- },
193
- hasTokens: false,
194
- description: '~/.copilot/session-state/*/events.jsonl (no token data)',
195
- },
196
-
197
- // -----------------------------------------------------------------------
198
- // Antigravity (VS Code variant) - mostly cache; no token data
199
- // -----------------------------------------------------------------------
200
- {
201
- key: 'antigravity',
202
- name: 'Antigravity',
203
- kind: 'dir',
204
- envVar: 'ANTIGRAVITY_HOME',
205
- candidatePaths: () => {
206
- const base = env.ANTIGRAVITY_HOME || join(APP_SUPPORT, 'Antigravity');
207
- return [
208
- base,
209
- join(HOME, '.antigravity'),
210
- ];
211
- },
212
- count: (p) => {
213
- if (!p) return 0;
214
- let n = 0;
215
- try {
216
- for (const e of readdirSync(p)) {
217
- const full = join(p, e);
218
- if (statSync(full).isDirectory()) n++;
219
- }
220
- } catch {}
221
- return n;
222
- },
223
- hasTokens: false,
224
- description: '~/Library/Application Support/Antigravity (no token data)',
225
- },
226
-
227
- // -----------------------------------------------------------------------
228
- // Gemini CLI
229
- // -----------------------------------------------------------------------
230
- {
231
- key: 'gemini',
232
- name: 'Gemini CLI',
233
- kind: 'protobuf',
234
- envVar: 'GEMINI_HOME',
235
- candidatePaths: () => {
236
- const base = env.GEMINI_HOME || join(HOME, '.gemini');
237
- return [
238
- join(base, 'antigravity', 'conversations'),
239
- join(base, 'conversations'),
240
- ];
241
- },
242
- count: (p) => {
243
- if (!p) return 0;
244
- let n = 0;
245
- try {
246
- for (const f of readdirSync(p)) {
247
- if (f.endsWith('.pb')) n++;
248
- }
249
- } catch {}
250
- return n;
251
- },
252
- hasTokens: false,
253
- description: '~/.gemini/antigravity/conversations/*.pb (binary, no token data)',
254
- },
255
- ];
256
-
257
- // ---------------------------------------------------------------------------
258
- // Public: run all detectors
259
- // ---------------------------------------------------------------------------
260
-
261
23
  export function detectAll(opts = {}) {
262
24
  const override = opts.override || (env.AI_USAGE_PATHS_JSON
263
25
  ? safeParseJSON(env.AI_USAGE_PATHS_JSON)
264
26
  : {});
265
27
 
266
28
  const results = [];
267
- for (const def of DETECTORS) {
29
+ for (const def of TOOLS) {
268
30
  let candidatePaths = def.candidatePaths();
269
31
 
270
32
  // Apply override if user supplied one
@@ -292,12 +54,4 @@ export function detectAll(opts = {}) {
292
54
  return results;
293
55
  }
294
56
 
295
- function safeParseJSON(s) {
296
- try { return JSON.parse(s); } catch { return {}; }
297
- }
298
-
299
- // ---------------------------------------------------------------------------
300
- // Public: just keys in order (for stable UI columns)
301
- // ---------------------------------------------------------------------------
302
-
303
- export const TOOL_ORDER = DETECTORS.map(d => d.key);
57
+ export { TOOL_ORDER };
package/src/loaders.js CHANGED
@@ -207,6 +207,113 @@ function walkJsonl(root, prefix) {
207
207
  return out;
208
208
  }
209
209
 
210
+ // Walks Claude's projects/ tree. Top-level .jsonl files are sessions; files
211
+ // under any `subagents/` subdir are deliberately skipped (their usage is
212
+ // already represented in the parent session's assistant messages).
213
+ function walkClaudeSessions(root) {
214
+ const out = [];
215
+ function walk(dir) {
216
+ let entries;
217
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
218
+ for (const e of entries) {
219
+ const full = join(dir, e.name);
220
+ if (e.isDirectory()) {
221
+ if (e.name === 'subagents') continue;
222
+ walk(full);
223
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) {
224
+ out.push(full);
225
+ }
226
+ }
227
+ }
228
+ walk(root);
229
+ return out;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Claude Code JSONL loader
234
+ // ---------------------------------------------------------------------------
235
+ //
236
+ // Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
237
+ // Per assistant line, `message.usage` carries the per-turn token counts; we
238
+ // dedupe by `message.id` (Claude Code emits streaming duplicates) and sum
239
+ // across unique messages. cwd / model / timestamp come from the line itself.
240
+
241
+ async function loadClaudeJsonl(rootDir) {
242
+ const records = [];
243
+ const errors = [];
244
+ const files = walkClaudeSessions(rootDir);
245
+
246
+ for (const f of files) {
247
+ try {
248
+ const rec = await parseClaudeSession(f);
249
+ if (rec) records.push(rec);
250
+ } catch (e) {
251
+ errors.push(`${f}: ${e.message}`);
252
+ }
253
+ }
254
+ return { records, errors };
255
+ }
256
+
257
+ async function parseClaudeSession(path) {
258
+ const sessionId = path.split('/').pop().replace('.jsonl', '');
259
+ const seenIds = new Set();
260
+ let cwd = '';
261
+ let model = '';
262
+ let firstTs = null;
263
+
264
+ let tokensInput = 0;
265
+ let tokensOutput = 0;
266
+ let tokensCacheRead = 0;
267
+ let tokensCacheWrite = 0;
268
+
269
+ const stream = createReadStream(path, { encoding: 'utf8' });
270
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
271
+ for await (const line of rl) {
272
+ if (!line) continue;
273
+ let ev;
274
+ try { ev = JSON.parse(line); } catch { continue; }
275
+ if (ev.type !== 'assistant') continue;
276
+ const msg = ev.message || {};
277
+ const usage = msg.usage;
278
+ if (!usage) continue;
279
+ const id = msg.id;
280
+ if (id) {
281
+ if (seenIds.has(id)) continue;
282
+ seenIds.add(id);
283
+ }
284
+ if (!cwd && typeof ev.cwd === 'string') cwd = ev.cwd;
285
+ if (!model && typeof msg.model === 'string') model = msg.model;
286
+ const ts = parseIsoMs(ev.timestamp);
287
+ if (firstTs === null && ts !== null) firstTs = ts;
288
+ tokensInput += usage.input_tokens || 0;
289
+ tokensOutput += usage.output_tokens || 0;
290
+ tokensCacheRead += usage.cache_read_input_tokens || 0;
291
+ tokensCacheWrite += usage.cache_creation_input_tokens || 0;
292
+ }
293
+
294
+ const tot = tokensInput + tokensOutput + tokensCacheRead + tokensCacheWrite;
295
+ if (tot === 0) return null;
296
+ const ts = firstTs ?? Date.now();
297
+
298
+ return {
299
+ tool: 'claude',
300
+ sessionId,
301
+ project: compactHome(cwd),
302
+ title: '',
303
+ week: isoWeekKey(ts),
304
+ month: monthKey(ts),
305
+ ts,
306
+ tokensInput,
307
+ tokensOutput,
308
+ tokensCacheRead,
309
+ tokensCacheWrite,
310
+ tokensReasoning: 0,
311
+ tokensTotal: tot,
312
+ cost: 0,
313
+ model,
314
+ };
315
+ }
316
+
210
317
  // ---------------------------------------------------------------------------
211
318
  // Public: load all session records for given detectors
212
319
  // ---------------------------------------------------------------------------
@@ -226,8 +333,12 @@ export async function loadAll(detections) {
226
333
  const r = await loadCodexRollouts(d.path);
227
334
  all.push(...r.records);
228
335
  if (r.errors) errors.push(...r.errors);
336
+ } else if (d.key === 'claude') {
337
+ const r = await loadClaudeJsonl(d.path);
338
+ all.push(...r.records);
339
+ if (r.errors) errors.push(...r.errors);
229
340
  }
230
- // For other tools (claude, copilot, antigravity, gemini), we only have
341
+ // For other tools (copilot, antigravity, gemini), we only have
231
342
  // presence info — no token data to load.
232
343
  }
233
344
  return { records: all, errors };
package/src/markdown.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // copies cleanly into issues, PRs, Notion, etc.
4
4
 
5
5
  import {
6
- perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
6
+ perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
7
7
  MONTH_NAMES,
8
8
  } from './aggregate.js';
9
+ import { getToolBarChar, TOOLS } from './tools.js';
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Number formatting helpers (re-implemented locally to avoid TUI deps)
@@ -41,6 +42,29 @@ function bar(value, max, width) {
41
42
  return '█'.repeat(filled) + '░'.repeat(width - filled);
42
43
  }
43
44
 
45
+ // Markdown has no color, so each tool's stacked-bar segment is shown with
46
+ // a distinct block character. The character for each tool is defined in
47
+ // src/tools.js alongside its color.
48
+ function stackedBar(byTool, width) {
49
+ const total = Object.values(byTool).reduce((a, b) => a + b, 0);
50
+ if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
51
+ const entries = Object.entries(byTool)
52
+ .filter(([, v]) => v > 0)
53
+ .sort((a, b) => b[1] - a[1]);
54
+ let out = '';
55
+ let x = 0;
56
+ for (let i = 0; i < entries.length; i++) {
57
+ const [tool, value] = entries[i];
58
+ const segW = i === entries.length - 1
59
+ ? width - x
60
+ : Math.round((value / total) * width);
61
+ if (segW <= 0) continue;
62
+ out += getToolBarChar(tool).repeat(segW);
63
+ x += segW;
64
+ }
65
+ return out;
66
+ }
67
+
44
68
  function pct(x) { return (x * 100).toFixed(1) + '%'; }
45
69
 
46
70
  function mdEscape(s) {
@@ -151,15 +175,53 @@ export function renderMarkdown({
151
175
  // Per Project ---------------------------------------------------------
152
176
  const projects = perProject(records);
153
177
  if (projects.length > 0) {
154
- const max = projects[0].tokensTotal;
155
178
  out.push(`## Per Project`);
156
179
  out.push(``);
157
- out.push(`| Tool | Project | Sessions | Input | Output | Cache | Total | Dist |`);
158
- out.push(`|---|---|---:|---:|---:|---:|---:|---|`);
180
+ out.push(`| Project | Sessions | Input | Output | Cache | Total | Dist |`);
181
+ out.push(`|---|---:|---:|---:|---:|---:|---|`);
159
182
  for (const p of projects) {
160
183
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
161
184
  const proj = compactHome(p.project);
162
- out.push(`| ${mdEscape(p.tool)} | \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 20)} |`);
185
+ out.push(`| \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${stackedBar(p.byTool, 20)} |`);
186
+ }
187
+ out.push(``);
188
+ const legend = TOOLS
189
+ .filter(t => t.hasTokens)
190
+ .map(t => `\`${t.barChar}\` ${t.label}`)
191
+ .join(' · ');
192
+ out.push(`_Bar legend: ${legend} · \`·\` other_`);
193
+ out.push(``);
194
+ }
195
+
196
+ // Per Tool ------------------------------------------------------------
197
+ const toolRows = perTool(records);
198
+ if (toolRows.length > 0) {
199
+ const max = toolRows[0].tokensTotal;
200
+ out.push(`## Per Tool`);
201
+ out.push(``);
202
+ out.push(`| Tool | Sessions | Input | Output | Cache | Total | Cost | Avg/sess | Dist |`);
203
+ out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
204
+ for (const p of toolRows) {
205
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
206
+ out.push(`| ${mdEscape(p.tool)} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${fmtCost(p.cost)} | ${fmtCompact(p.avg)} | ${bar(p.tokensTotal, max, 20)} |`);
207
+ }
208
+ out.push(``);
209
+ }
210
+
211
+ // Per Tool per Month ---------------------------------------------------
212
+ const tpmRows = perToolPerMonth(records);
213
+ if (tpmRows.length > 0) {
214
+ const max = tpmRows[0].tokensTotal;
215
+ out.push(`## Per Tool per Month`);
216
+ out.push(``);
217
+ out.push(`| Tool | Month | Sessions | Input | Output | Cache | Total | Dist |`);
218
+ out.push(`|---|---|---:|---:|---:|---:|---:|---|`);
219
+ for (const p of tpmRows) {
220
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
221
+ const yyyy = p.month.slice(0, 4);
222
+ const mm = p.month.slice(5, 7);
223
+ const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
224
+ out.push(`| ${mdEscape(p.tool)} | ${label} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 18)} |`);
163
225
  }
164
226
  out.push(``);
165
227
  }
@@ -167,16 +229,15 @@ export function renderMarkdown({
167
229
  // Per Month -----------------------------------------------------------
168
230
  const months = perMonth(records);
169
231
  if (months.length > 0) {
170
- const max = Math.max(...months.map(m => m.tokensTotal));
171
232
  out.push(`## Per Month`);
172
233
  out.push(``);
173
- out.push(`| Month | Sessions | Input | Output | Total | OC | CX | MM | Dist |`);
174
- out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
234
+ out.push(`| Month | Sessions | Input | Output | Total | Dist |`);
235
+ out.push(`|---|---:|---:|---:|---:|---|`);
175
236
  for (const m of months) {
176
237
  const yyyy = m.month.slice(0, 4);
177
238
  const mm = m.month.slice(5, 7);
178
239
  const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
179
- out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${m.byTool.opencode ? fmtCompact(m.byTool.opencode) : '—'} | ${m.byTool.codex ? fmtCompact(m.byTool.codex) : '—'} | ${m.byTool.mimocode ? fmtCompact(m.byTool.mimocode) : '—'} | ${bar(m.tokensTotal, max, 20)} |`);
240
+ out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${stackedBar(m.byTool, 20)} |`);
180
241
  }
181
242
  out.push(``);
182
243
  }
@@ -184,13 +245,12 @@ export function renderMarkdown({
184
245
  // Per Week ------------------------------------------------------------
185
246
  const weeks = perWeek(records);
186
247
  if (weeks.length > 0) {
187
- const max = Math.max(...weeks.map(w => w.tokensTotal));
188
248
  out.push(`## Per Week`);
189
249
  out.push(``);
190
- out.push(`| Week | Sessions | Input | Output | Total | OC | CX | MM | Dist |`);
191
- out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
250
+ out.push(`| Week | Sessions | Input | Output | Total | Dist |`);
251
+ out.push(`|---|---:|---:|---:|---:|---|`);
192
252
  for (const w of weeks) {
193
- out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${w.byTool.opencode ? fmtCompact(w.byTool.opencode) : '—'} | ${w.byTool.codex ? fmtCompact(w.byTool.codex) : '—'} | ${w.byTool.mimocode ? fmtCompact(w.byTool.mimocode) : '—'} | ${bar(w.tokensTotal, max, 18)} |`);
253
+ out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${stackedBar(w.byTool, 18)} |`);
194
254
  }
195
255
  out.push(``);
196
256
  }
package/src/render.js CHANGED
@@ -6,9 +6,10 @@ import boxen from 'boxen';
6
6
  import gradient from 'gradient-string';
7
7
  import process from 'node:process';
8
8
  import {
9
- perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
9
+ perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
10
10
  MONTH_NAMES,
11
11
  } from './aggregate.js';
12
+ import { getToolColor } from './tools.js';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Terminal width detection
@@ -63,34 +64,45 @@ export function fmtCost(n) {
63
64
  // Color helpers
64
65
  // ---------------------------------------------------------------------------
65
66
 
66
- const TOOL_COLORS = {
67
- opencode: 'cyan',
68
- codex: 'magenta',
69
- mimocode: 'yellow',
70
- claude: 'blue',
71
- copilot: 'green',
72
- antigravity: 'red',
73
- gemini: 'gray',
74
- };
75
-
76
- export function toolColor(t) { return TOOL_COLORS[t] || 'white'; }
77
- export function colorize(t, c) { return chalk.hex(toHex(c))(t); }
78
-
79
- function toHex(name) {
80
- const m = {
81
- red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
82
- blue: '#8be9fd', magenta: '#ff79c6', cyan: '#8be9fd',
83
- white: '#f8f8f2', gray: '#6272a4',
84
- };
85
- return m[name] || '#ffffff';
86
- }
67
+ export function toolColor(t) { return getToolColor(t); }
68
+ export function colorize(t, c) { return chalk.hex(c)(t); }
69
+
70
+ // Cool-to-warm gradient: small bars feel subtle, large bars pop.
71
+ const BAR_GRADIENT = gradient(['#3b82f6', '#14b8a6', '#f1fa8c', '#ff9e64']);
87
72
 
88
- function bar(value, max, width, color) {
73
+ function bar(value, max, width) {
89
74
  if (max <= 0) return ' '.repeat(width);
90
75
  const pct = Math.max(0, Math.min(1, value / max));
91
76
  const filled = Math.round(pct * width);
92
77
  const empty = width - filled;
93
- return chalk.hex(toHex(color))('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
78
+ if (filled === 0) return chalk.gray('░'.repeat(width));
79
+ return BAR_GRADIENT('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
80
+ }
81
+
82
+ // Stacked bar — one segment per tool, widths proportional to that tool's
83
+ // share of the row's total. Used wherever a row spans multiple tools
84
+ // (per-project / per-month / per-week). Single-tool rows are just one
85
+ // colored segment; no change in feel.
86
+ function stackedBar(byTool, width) {
87
+ const total = Object.values(byTool).reduce((a, b) => a + b, 0);
88
+ if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
89
+ const entries = Object.entries(byTool)
90
+ .filter(([, v]) => v > 0)
91
+ .sort((a, b) => b[1] - a[1]);
92
+ let out = '';
93
+ let x = 0;
94
+ for (let i = 0; i < entries.length; i++) {
95
+ const [tool, value] = entries[i];
96
+ // Last segment fills the remaining width so rounding doesn't leave
97
+ // a visible gap or overshoot.
98
+ const segW = i === entries.length - 1
99
+ ? width - x
100
+ : Math.round((value / total) * width);
101
+ if (segW <= 0) continue;
102
+ out += chalk.hex(toolColor(tool))('█'.repeat(segW));
103
+ x += segW;
104
+ }
105
+ return out;
94
106
  }
95
107
 
96
108
  function shortModel(m) {
@@ -250,23 +262,60 @@ function pct(x) { return (x * 100).toFixed(1) + '%'; }
250
262
  export function renderPerProject(records) {
251
263
  const items = perProject(records);
252
264
  if (items.length === 0) return '';
253
- const max = items[0].tokensTotal;
254
265
  const barW = NARROW ? 10 : 18;
255
266
 
256
267
  // Narrow mode: drop In/Out/Cache columns, just show n + Total + Dist
257
268
  const head = NARROW
258
- ? [chalk.bold('Tool'), chalk.bold('Project'), chalk.bold('n'),
269
+ ? [chalk.bold('Project'), chalk.bold('n'),
259
270
  chalk.bold('Total'), chalk.bold('Dist')]
260
- : [chalk.bold('Tool'), chalk.bold('Project'), chalk.bold('n'),
271
+ : [chalk.bold('Project'), chalk.bold('n'),
261
272
  chalk.bold('In'), chalk.bold('Out'),
262
273
  chalk.bold('Cache'), chalk.bold('Total'), chalk.bold('Dist')];
263
274
  const t = new Table({ head, style: { head: [], border: [] } });
264
275
 
276
+ for (const p of items) {
277
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
278
+ const row = [truncEnd(p.project, NARROW ? 28 : 40), fmtInt(p.n)];
279
+ if (!NARROW) {
280
+ row.push(
281
+ fmtCompact(p.tokensInput),
282
+ fmtCompact(p.tokensOutput),
283
+ fmtCompact(cache),
284
+ );
285
+ }
286
+ row.push(fmtCompact(p.tokensTotal), stackedBar(p.byTool, barW));
287
+ t.push(row);
288
+ }
289
+ return boxen(t.toString(), {
290
+ title: chalk.bold('Per Project'),
291
+ borderStyle: 'round',
292
+ borderColor: 'cyan',
293
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
294
+ });
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Per Tool (detailed — complements the brief summary in Overview)
299
+ // ---------------------------------------------------------------------------
300
+
301
+ export function renderPerTool(records) {
302
+ const items = perTool(records);
303
+ if (items.length === 0) return '';
304
+ const max = items[0].tokensTotal;
305
+ const barW = NARROW ? 10 : 18;
306
+
307
+ const head = NARROW
308
+ ? [chalk.bold('Tool'), chalk.bold('n'),
309
+ chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')]
310
+ : [chalk.bold('Tool'), chalk.bold('n'),
311
+ chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
312
+ chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')];
313
+ const t = new Table({ head, style: { head: [], border: [] } });
314
+
265
315
  for (const p of items) {
266
316
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
267
317
  const row = [
268
318
  colorize(p.tool, toolColor(p.tool)),
269
- truncEnd(p.project, NARROW ? 26 : 38),
270
319
  fmtInt(p.n),
271
320
  ];
272
321
  if (!NARROW) {
@@ -278,12 +327,63 @@ export function renderPerProject(records) {
278
327
  }
279
328
  row.push(
280
329
  fmtCompact(p.tokensTotal),
281
- bar(p.tokensTotal, max, barW, toolColor(p.tool)),
330
+ fmtCost(p.cost),
331
+ fmtCompact(p.avg),
332
+ bar(p.tokensTotal, max, barW),
282
333
  );
283
334
  t.push(row);
284
335
  }
285
336
  return boxen(t.toString(), {
286
- title: chalk.bold('Per Project'),
337
+ title: chalk.bold('Per Tool'),
338
+ borderStyle: 'round',
339
+ borderColor: 'magenta',
340
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
341
+ });
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Per Tool per Month (cross-tab — one row per (tool, month))
346
+ // ---------------------------------------------------------------------------
347
+
348
+ export function renderPerToolPerMonth(records) {
349
+ const items = perToolPerMonth(records);
350
+ if (items.length === 0) return '';
351
+ const max = items[0].tokensTotal;
352
+ const barW = NARROW ? 8 : 16;
353
+
354
+ const head = NARROW
355
+ ? [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
356
+ chalk.bold('Total'), chalk.bold('Dist')]
357
+ : [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
358
+ chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
359
+ chalk.bold('Total'), chalk.bold('Dist')];
360
+ const t = new Table({ head, style: { head: [], border: [] } });
361
+
362
+ for (const p of items) {
363
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
364
+ const yyyy = p.month.slice(0, 4);
365
+ const mm = p.month.slice(5, 7);
366
+ const monthLabel = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
367
+ const row = [
368
+ colorize(p.tool, toolColor(p.tool)),
369
+ chalk.bold(monthLabel),
370
+ fmtInt(p.n),
371
+ ];
372
+ if (!NARROW) {
373
+ row.push(
374
+ fmtCompact(p.tokensInput),
375
+ fmtCompact(p.tokensOutput),
376
+ fmtCompact(cache),
377
+ );
378
+ }
379
+ row.push(
380
+ fmtCompact(p.tokensTotal),
381
+ bar(p.tokensTotal, max, barW),
382
+ );
383
+ t.push(row);
384
+ }
385
+ return boxen(t.toString(), {
386
+ title: chalk.bold('Per Tool per Month'),
287
387
  borderStyle: 'round',
288
388
  borderColor: 'cyan',
289
389
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
@@ -297,64 +397,42 @@ export function renderPerProject(records) {
297
397
  export function renderPerMonth(records) {
298
398
  const items = perMonth(records);
299
399
  if (items.length === 0) return '';
300
- const max = Math.max(...items.map(p => p.tokensTotal));
301
400
  const barW = NARROW ? 10 : 16;
302
- const t1 = perTool(records);
303
401
 
304
- // Narrow mode: drop the byTool columns (OC/CX/MM), keep core metrics
305
- const head = NARROW
306
- ? [chalk.bold('Month'), chalk.bold('n'),
307
- chalk.bold('Input'), chalk.bold('Output'),
308
- chalk.bold('Total'), chalk.bold('Dist')]
309
- : [chalk.bold('Month'), chalk.bold('n'),
310
- chalk.bold('Input'), chalk.bold('Output'),
311
- chalk.bold('Total'), chalk.bold('OC'),
312
- chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
402
+ const head = [
403
+ chalk.bold('Month'), chalk.bold('n'),
404
+ chalk.bold('Input'), chalk.bold('Output'),
405
+ chalk.bold('Total'), chalk.bold('Dist'),
406
+ ];
313
407
  const t = new Table({ head, style: { head: [], border: [] } });
314
408
 
315
409
  for (const p of items) {
316
410
  const yyyy = p.month.slice(0, 4);
317
411
  const mm = p.month.slice(5, 7);
318
412
  const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
319
- const row = [
413
+ t.push([
320
414
  chalk.bold(label),
321
415
  fmtInt(p.n),
322
416
  fmtCompact(p.tokensInput),
323
417
  fmtCompact(p.tokensOutput),
324
418
  fmtCompact(p.tokensTotal),
325
- ];
326
- if (!NARROW) {
327
- row.push(
328
- p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
329
- p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
330
- p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
331
- );
332
- }
333
- row.push(bar(p.tokensTotal, max, barW, 'green'));
334
- t.push(row);
419
+ stackedBar(p.byTool, barW),
420
+ ]);
335
421
  }
336
422
 
337
423
  // TOTAL row
338
424
  const tot = overall(records);
339
- const totalRow = [
425
+ t.push([
340
426
  chalk.bgGray.white.bold(' TOTAL '),
341
427
  chalk.bgGray.white.bold(fmtInt(records.length)),
342
428
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
343
429
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
344
430
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
345
- ];
346
- if (!NARROW) {
347
- totalRow.push(
348
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
349
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
350
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
351
- );
352
- }
353
- totalRow.push('');
354
- t.push(totalRow);
431
+ '',
432
+ ]);
355
433
 
356
434
  return boxen(t.toString(), {
357
- title: chalk.bold('Per Bulan (Monthly)'),
435
+ title: chalk.bold('Per Month'),
358
436
  borderStyle: 'round',
359
437
  borderColor: 'green',
360
438
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
@@ -368,60 +446,38 @@ export function renderPerMonth(records) {
368
446
  export function renderPerWeek(records) {
369
447
  const items = perWeek(records);
370
448
  if (items.length === 0) return '';
371
- const max = Math.max(...items.map(p => p.tokensTotal));
372
449
  const barW = NARROW ? 10 : 14;
373
- const t1 = perTool(records);
374
450
 
375
- const head = NARROW
376
- ? [chalk.bold('ISO Week'), chalk.bold('n'),
377
- chalk.bold('Input'), chalk.bold('Output'),
378
- chalk.bold('Total'), chalk.bold('Dist')]
379
- : [chalk.bold('ISO Week'), chalk.bold('n'),
380
- chalk.bold('Input'), chalk.bold('Output'),
381
- chalk.bold('Total'), chalk.bold('OC'),
382
- chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
451
+ const head = [
452
+ chalk.bold('ISO Week'), chalk.bold('n'),
453
+ chalk.bold('Input'), chalk.bold('Output'),
454
+ chalk.bold('Total'), chalk.bold('Dist'),
455
+ ];
383
456
  const t = new Table({ head, style: { head: [], border: [] } });
384
457
 
385
458
  for (const p of items) {
386
- const dominantTool = Object.entries(p.byTool).sort((a, b) => b[1] - a[1])[0]?.[0] || 'opencode';
387
- const row = [
459
+ t.push([
388
460
  chalk.bold(p.week),
389
461
  fmtInt(p.n),
390
462
  fmtCompact(p.tokensInput),
391
463
  fmtCompact(p.tokensOutput),
392
464
  fmtCompact(p.tokensTotal),
393
- ];
394
- if (!NARROW) {
395
- row.push(
396
- p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
397
- p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
398
- p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
399
- );
400
- }
401
- row.push(bar(p.tokensTotal, max, barW, toolColor(dominantTool)));
402
- t.push(row);
465
+ stackedBar(p.byTool, barW),
466
+ ]);
403
467
  }
404
468
  // TOTAL row
405
469
  const tot = overall(records);
406
- const totalRow = [
470
+ t.push([
407
471
  chalk.bgGray.white.bold(' TOTAL '),
408
472
  chalk.bgGray.white.bold(fmtInt(records.length)),
409
473
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
410
474
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
411
475
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
412
- ];
413
- if (!NARROW) {
414
- totalRow.push(
415
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
416
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
417
- chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
418
- );
419
- }
420
- totalRow.push('');
421
- t.push(totalRow);
476
+ '',
477
+ ]);
422
478
 
423
479
  return boxen(t.toString(), {
424
- title: chalk.bold('Per Minggu (Weekly)'),
480
+ title: chalk.bold('Per Week'),
425
481
  borderStyle: 'round',
426
482
  borderColor: 'magenta',
427
483
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
package/src/tools.js ADDED
@@ -0,0 +1,259 @@
1
+ // Single source of truth for every AI coding tool this analyzer supports.
2
+ //
3
+ // Each tool entry describes both:
4
+ // - How to find it on disk (kind, envVar, candidatePaths, count)
5
+ // - How to render it in the report (name, label, color, barChar)
6
+ //
7
+ // To add a new tool, append one entry to TOOLS. If the tool exposes token
8
+ // data, also wire a loader branch in src/loaders.js.
9
+
10
+ import { statSync, readdirSync, existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir, platform } from 'node:os';
13
+ import { env } from 'node:process';
14
+ import { createRequire } from 'node:module';
15
+ const require = createRequire(import.meta.url);
16
+
17
+ const HOME = homedir();
18
+ const OS = platform(); // 'darwin' | 'linux' | 'win32'
19
+
20
+ const APP_SUPPORT = OS === 'darwin'
21
+ ? join(HOME, 'Library', 'Application Support')
22
+ : env.XDG_DATA_HOME
23
+ ? join(env.XDG_DATA_HOME, '..') // XDG_DATA_HOME/../ = ~/.local/share
24
+ : join(HOME, '.local', 'share');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Reusable count helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function countJsonlRecursive(dir) {
31
+ if (!dir) return 0;
32
+ let n = 0;
33
+ function walk(d) {
34
+ try {
35
+ for (const e of readdirSync(d, { withFileTypes: true })) {
36
+ const full = join(d, e.name);
37
+ if (e.isDirectory()) walk(full);
38
+ else if (e.isFile() && e.name.endsWith('.jsonl')) n++;
39
+ }
40
+ } catch {}
41
+ }
42
+ walk(dir);
43
+ return n;
44
+ }
45
+
46
+ function countSqliteRows(dbPath) {
47
+ if (!dbPath) return 0;
48
+ try {
49
+ const { DatabaseSync } = require('node:sqlite');
50
+ const db = new DatabaseSync(dbPath, { readOnly: true });
51
+ return db.prepare('SELECT COUNT(*) AS c FROM session').get().c;
52
+ } catch { return 0; }
53
+ }
54
+
55
+ function countSubdirs(dir) {
56
+ if (!dir) return 0;
57
+ let n = 0;
58
+ try {
59
+ for (const e of readdirSync(dir)) {
60
+ const full = join(dir, e);
61
+ if (statSync(full).isDirectory()) n++;
62
+ }
63
+ } catch {}
64
+ return n;
65
+ }
66
+
67
+ function countProtobuf(dir) {
68
+ if (!dir) return 0;
69
+ let n = 0;
70
+ try {
71
+ for (const f of readdirSync(dir)) {
72
+ if (f.endsWith('.pb')) n++;
73
+ }
74
+ } catch {}
75
+ return n;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Tool definitions
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export const TOOLS = [
83
+ // -----------------------------------------------------------------------
84
+ // Claude Code
85
+ // -----------------------------------------------------------------------
86
+ {
87
+ key: 'claude',
88
+ name: 'Claude Code',
89
+ label: 'claude',
90
+ color: '#ff9e64', // orange
91
+ barChar: '█',
92
+ kind: 'jsonl',
93
+ envVar: 'CLAUDE_HOME',
94
+ candidatePaths: () => [
95
+ env.CLAUDE_HOME
96
+ ? join(env.CLAUDE_HOME, 'projects')
97
+ : join(HOME, '.claude', 'projects'),
98
+ ],
99
+ count: countJsonlRecursive,
100
+ hasTokens: true,
101
+ description: '~/.claude/projects/*/<UUID>.jsonl (per-message usage in assistant lines)',
102
+ },
103
+
104
+ // -----------------------------------------------------------------------
105
+ // Codex
106
+ // -----------------------------------------------------------------------
107
+ {
108
+ key: 'codex',
109
+ name: 'Codex',
110
+ label: 'codex',
111
+ color: '#87ceeb', // sky blue
112
+ barChar: '▓',
113
+ kind: 'jsonl-rollout',
114
+ envVar: 'CODEX_HOME',
115
+ candidatePaths: () => [
116
+ env.CODEX_HOME || join(HOME, '.codex', 'sessions'),
117
+ ],
118
+ count: countJsonlRecursive,
119
+ hasTokens: true,
120
+ description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
121
+ },
122
+
123
+ // -----------------------------------------------------------------------
124
+ // OpenCode
125
+ // -----------------------------------------------------------------------
126
+ {
127
+ key: 'opencode',
128
+ name: 'OpenCode',
129
+ label: 'opencode',
130
+ color: '#f8f8f2', // white
131
+ barChar: '▒',
132
+ kind: 'sqlite',
133
+ envVar: 'OPENCODE_HOME',
134
+ candidatePaths: () => [
135
+ env.OPENCODE_HOME
136
+ ? join(env.OPENCODE_HOME, 'opencode.db')
137
+ : join(HOME, '.local', 'share', 'opencode', 'opencode.db'),
138
+ ],
139
+ count: countSqliteRows,
140
+ hasTokens: true,
141
+ description: '~/.local/share/opencode/opencode.db (tokens + cost)',
142
+ },
143
+
144
+ // -----------------------------------------------------------------------
145
+ // MimoCode (same schema as OpenCode)
146
+ // -----------------------------------------------------------------------
147
+ {
148
+ key: 'mimocode',
149
+ name: 'MimoCode',
150
+ label: 'mimocode',
151
+ color: '#f1fa8c', // yellow
152
+ barChar: '░',
153
+ kind: 'sqlite',
154
+ envVar: 'MIMOCODE_HOME',
155
+ candidatePaths: () => [
156
+ env.MIMOCODE_HOME
157
+ ? join(env.MIMOCODE_HOME, 'mimocode.db')
158
+ : join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
159
+ ],
160
+ count: countSqliteRows,
161
+ hasTokens: true,
162
+ description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
163
+ },
164
+
165
+ // -----------------------------------------------------------------------
166
+ // GitHub Copilot CLI (presence only)
167
+ // -----------------------------------------------------------------------
168
+ {
169
+ key: 'copilot',
170
+ name: 'GitHub Copilot',
171
+ label: 'copilot',
172
+ color: '#3b82f6', // deep blue
173
+ barChar: '·',
174
+ kind: 'jsonl-events',
175
+ envVar: 'COPILOT_HOME',
176
+ candidatePaths: () => {
177
+ const base = env.COPILOT_HOME || join(HOME, '.copilot');
178
+ return [
179
+ join(base, 'session-state'),
180
+ base,
181
+ ];
182
+ },
183
+ count: countJsonlRecursive,
184
+ hasTokens: false,
185
+ description: '~/.copilot/session-state/*/events.jsonl (no token data)',
186
+ },
187
+
188
+ // -----------------------------------------------------------------------
189
+ // Antigravity (VS Code variant) - mostly cache; no token data
190
+ // -----------------------------------------------------------------------
191
+ {
192
+ key: 'antigravity',
193
+ name: 'Antigravity',
194
+ label: 'antigravity',
195
+ color: '#ff5555', // red
196
+ barChar: '·',
197
+ kind: 'dir',
198
+ envVar: 'ANTIGRAVITY_HOME',
199
+ candidatePaths: () => [
200
+ env.ANTIGRAVITY_HOME || APP_SUPPORT + '/Antigravity',
201
+ join(HOME, '.antigravity'),
202
+ ],
203
+ count: countSubdirs,
204
+ hasTokens: false,
205
+ description: '~/Library/Application Support/Antigravity (no token data)',
206
+ },
207
+
208
+ // -----------------------------------------------------------------------
209
+ // Gemini CLI
210
+ // -----------------------------------------------------------------------
211
+ {
212
+ key: 'gemini',
213
+ name: 'Gemini CLI',
214
+ label: 'gemini',
215
+ color: '#14b8a6', // teal
216
+ barChar: '·',
217
+ kind: 'protobuf',
218
+ envVar: 'GEMINI_HOME',
219
+ candidatePaths: () => {
220
+ const base = env.GEMINI_HOME || join(HOME, '.gemini');
221
+ return [
222
+ join(base, 'antigravity', 'conversations'),
223
+ join(base, 'conversations'),
224
+ ];
225
+ },
226
+ count: countProtobuf,
227
+ hasTokens: false,
228
+ description: '~/.gemini/antigravity/conversations/*.pb (binary, no token data)',
229
+ },
230
+ ];
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Lookup helpers — consumers should use these instead of indexing TOOLS
234
+ // directly so the null-handling stays in one place.
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const TOOL_BY_KEY = new Map(TOOLS.map(t => [t.key, t]));
238
+ const FALLBACK_COLOR = '#ffffff';
239
+ const FALLBACK_BAR_CHAR = '·';
240
+
241
+ export function getTool(key) {
242
+ return TOOL_BY_KEY.get(key) || null;
243
+ }
244
+
245
+ export function getToolColor(key) {
246
+ return TOOL_BY_KEY.get(key)?.color ?? FALLBACK_COLOR;
247
+ }
248
+
249
+ export function getToolBarChar(key) {
250
+ return TOOL_BY_KEY.get(key)?.barChar ?? FALLBACK_BAR_CHAR;
251
+ }
252
+
253
+ export function getToolLabel(key) {
254
+ return TOOL_BY_KEY.get(key)?.label ?? key;
255
+ }
256
+
257
+ // Stable UI column order — derived from TOOLS so adding a tool only
258
+ // requires one edit in this file.
259
+ export const TOOL_ORDER = TOOLS.map(t => t.key);