ai-usage-analyzer 0.2.0 → 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/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 },