ai-usage-analyzer 0.3.2 → 0.4.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/render.js CHANGED
@@ -1,21 +1,31 @@
1
1
  // TUI renderer using chalk + cli-table3 + boxen + gradient-string.
2
2
 
3
- import chalk from 'chalk';
4
- import Table from 'cli-table3';
5
- import boxen from 'boxen';
6
- import gradient from 'gradient-string';
7
- import process from 'node:process';
8
- import { readFileSync } from 'node:fs';
9
- import { fileURLToPath } from 'node:url';
10
- import { dirname, join } from 'node:path';
3
+ import boxen from "boxen";
4
+ import chalk from "chalk";
5
+ import Table from "cli-table3";
6
+ import gradient from "gradient-string";
7
+ import { readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import process from "node:process";
10
+ import { fileURLToPath } from "node:url";
11
11
  import {
12
- perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
13
- MONTH_NAMES,
14
- } from './aggregate.js';
15
- import { getToolColor } from './tools.js';
12
+ MONTH_NAMES,
13
+ overall,
14
+ perMonth,
15
+ perProject,
16
+ perTool,
17
+ perToolPerMonth,
18
+ perWeek,
19
+ tokenBreakdown,
20
+ topSessions,
21
+ } from "./aggregate.js";
22
+ import { getToolColor } from "./tools.js";
16
23
 
17
24
  const VERSION = JSON.parse(
18
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
25
+ readFileSync(
26
+ join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
27
+ "utf8",
28
+ ),
19
29
  ).version;
20
30
 
21
31
  // ---------------------------------------------------------------------------
@@ -34,8 +44,8 @@ function detectWidth() {
34
44
  }
35
45
  // 3. tput cols (POSIX)
36
46
  try {
37
- const { execSync } = require('node:child_process');
38
- const out = execSync('tput cols 2>/dev/null', { encoding: 'utf8' });
47
+ const { execSync } = require("node:child_process");
48
+ const out = execSync("tput cols 2>/dev/null", { encoding: "utf8" });
39
49
  const n = parseInt(out.trim(), 10);
40
50
  if (Number.isFinite(n) && n > 0) return n;
41
51
  } catch {}
@@ -51,39 +61,53 @@ export const NARROW = TERM_WIDTH < 110;
51
61
  // ---------------------------------------------------------------------------
52
62
 
53
63
  export function fmtInt(n) {
54
- return Number(n).toLocaleString('en-US');
64
+ return Number(n).toLocaleString("en-US");
55
65
  }
56
66
 
57
67
  export function fmtCompact(n) {
58
68
  const a = Math.abs(n);
59
- if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
60
- if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
61
- if (a >= 1e3) return (n / 1e3).toFixed(1) + 'K';
69
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
70
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";
71
+ if (a >= 1e3) return (n / 1e3).toFixed(1) + "K";
62
72
  return String(n);
63
73
  }
64
74
 
65
75
  export function fmtCost(n) {
66
- if (!n) return '—';
67
- return '$' + Number(n).toFixed(2);
76
+ if (!n) return "—";
77
+ return "$" + Number(n).toFixed(2);
78
+ }
79
+ function fmtDuration(ms) {
80
+ if (!ms || ms <= 0) return chalk.dim("—");
81
+ const s = Math.round(ms / 1000);
82
+ if (s < 60) return `${s}s`;
83
+ const m = Math.floor(s / 60);
84
+ const rem = s % 60;
85
+ if (m < 60) return rem > 0 ? `${m}m${rem}s` : `${m}m`;
86
+ const h = Math.floor(m / 60);
87
+ const rm = m % 60;
88
+ return rm > 0 ? `${h}h${rm}m` : `${h}h`;
68
89
  }
69
-
70
90
  // ---------------------------------------------------------------------------
71
91
  // Color helpers
72
92
  // ---------------------------------------------------------------------------
73
93
 
74
- export function toolColor(t) { return getToolColor(t); }
75
- export function colorize(t, c) { return chalk.hex(c)(t); }
94
+ export function toolColor(t) {
95
+ return getToolColor(t);
96
+ }
97
+ export function colorize(t, c) {
98
+ return chalk.hex(c)(t);
99
+ }
76
100
 
77
101
  // Cool-to-warm gradient: small bars feel subtle, large bars pop.
78
- const BAR_GRADIENT = gradient(['#3b82f6', '#14b8a6', '#f1fa8c', '#ff9e64']);
102
+ const BAR_GRADIENT = gradient(["#3b82f6", "#14b8a6", "#f1fa8c", "#ff9e64"]);
79
103
 
80
104
  function bar(value, max, width) {
81
- if (max <= 0) return ' '.repeat(width);
105
+ if (max <= 0) return " ".repeat(width);
82
106
  const pct = Math.max(0, Math.min(1, value / max));
83
107
  const filled = Math.round(pct * width);
84
108
  const empty = width - filled;
85
- if (filled === 0) return chalk.gray('░'.repeat(width));
86
- return BAR_GRADIENT('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
109
+ if (filled === 0) return chalk.gray("░".repeat(width));
110
+ return BAR_GRADIENT("█".repeat(filled)) + chalk.gray("░".repeat(empty));
87
111
  }
88
112
 
89
113
  // Stacked bar — one segment per tool, widths proportional to that tool's
@@ -92,73 +116,86 @@ function bar(value, max, width) {
92
116
  // colored segment; no change in feel.
93
117
  function stackedBar(byTool, width) {
94
118
  const total = Object.values(byTool).reduce((a, b) => a + b, 0);
95
- if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
119
+ if (total <= 0 || width <= 0) return " ".repeat(Math.max(0, width));
96
120
  const entries = Object.entries(byTool)
97
121
  .filter(([, v]) => v > 0)
98
122
  .sort((a, b) => b[1] - a[1]);
99
- let out = '';
123
+ let out = "";
100
124
  let x = 0;
101
125
  for (let i = 0; i < entries.length; i++) {
102
126
  const [tool, value] = entries[i];
103
127
  // Last segment fills the remaining width so rounding doesn't leave
104
128
  // a visible gap or overshoot.
105
- const segW = i === entries.length - 1
106
- ? width - x
107
- : Math.round((value / total) * width);
129
+ const segW =
130
+ i === entries.length - 1
131
+ ? width - x
132
+ : Math.round((value / total) * width);
108
133
  if (segW <= 0) continue;
109
- out += chalk.hex(toolColor(tool))('█'.repeat(segW));
134
+ out += chalk.hex(toolColor(tool))("█".repeat(segW));
110
135
  x += segW;
111
136
  }
112
137
  return out;
113
138
  }
114
139
 
115
140
  function shortModel(m) {
116
- if (!m) return '—';
117
- if (typeof m === 'string' && m.trim().startsWith('{')) {
141
+ if (!m) return "—";
142
+ if (typeof m === "string" && m.trim().startsWith("{")) {
118
143
  try {
119
144
  const d = JSON.parse(m);
120
145
  const id = d.id || d.model || m;
121
146
  const prov = d.providerId || d.provider;
122
- return id + (prov ? ` (${prov})` : '');
123
- } catch { return m.slice(0, 30); }
147
+ return id + (prov ? ` (${prov})` : "");
148
+ } catch {
149
+ return m.slice(0, 30);
150
+ }
124
151
  }
125
152
  return m;
126
153
  }
127
154
 
128
155
  function truncMiddle(s, w) {
129
- if (!s) return '';
156
+ if (!s) return "";
130
157
  if (s.length <= w) return s;
131
158
  if (w <= 1) return s.slice(0, w);
132
159
  const head = Math.ceil((w - 1) / 2);
133
160
  const tail = Math.floor((w - 1) / 2);
134
- return s.slice(0, head) + '…' + s.slice(s.length - tail);
161
+ return s.slice(0, head) + "…" + s.slice(s.length - tail);
135
162
  }
136
163
 
137
164
  function truncEnd(s, w) {
138
- if (!s) return '';
165
+ if (!s) return "";
139
166
  if (s.length <= w) return s;
140
- return s.slice(0, w - 1) + '…';
167
+ return s.slice(0, w - 1) + "…";
141
168
  }
142
169
 
143
170
  // ---------------------------------------------------------------------------
144
171
  // Header
145
172
  // ---------------------------------------------------------------------------
146
173
 
147
- export function renderHeader({ totalSessions, totalTokens, totalCost, dateRange }) {
148
- const title = gradient.pastel.multiline('◆ AI TOKEN ANALYZER ◆');
149
- const sub = chalk.dim(`${totalSessions} sessions`) + ' • ' +
150
- chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
151
- (totalCost ? ' • ' + chalk.bold.yellow(`$${totalCost.toFixed(2)} USD`) : '');
152
- const range = (dateRange[0] && dateRange[1])
153
- ? chalk.dim.italic(`\n range: ${dateRange[0]} → ${dateRange[1]}`)
154
- : '';
174
+ export function renderHeader({
175
+ totalSessions,
176
+ totalTokens,
177
+ totalCost,
178
+ dateRange,
179
+ }) {
180
+ const title = gradient.pastel.multiline("◆ AI TOKEN ANALYZER ◆");
181
+ const sub =
182
+ chalk.dim(`${totalSessions} sessions`) +
183
+ " • " +
184
+ chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
185
+ (totalCost
186
+ ? " • " + chalk.bold.yellow(`$${totalCost.toFixed(2)} USD`)
187
+ : "");
188
+ const range =
189
+ dateRange[0] && dateRange[1]
190
+ ? chalk.dim.italic(`\n range: ${dateRange[0]} → ${dateRange[1]}`)
191
+ : "";
155
192
  return boxen(`${title}\n\n${sub}${range}`, {
156
193
  title: chalk.dim(`v${VERSION}`),
157
- titleAlignment: 'right',
158
- borderStyle: 'round',
159
- borderColor: 'magenta',
194
+ titleAlignment: "right",
195
+ borderStyle: "round",
196
+ borderColor: "magenta",
160
197
  padding: { top: 0, bottom: 0, left: 2, right: 2 },
161
- align: 'center',
198
+ align: "center",
162
199
  });
163
200
  }
164
201
 
@@ -170,38 +207,71 @@ export function renderDetections(detections) {
170
207
  // For narrow terminals, simplify to Tool + Status + Count + Tokens (drop Path)
171
208
  if (NARROW) {
172
209
  const t = new Table({
173
- head: [chalk.bold('Tool'), chalk.bold('Status'), chalk.bold('Count'), chalk.bold('Tokens')],
210
+ head: [
211
+ chalk.bold("Tool"),
212
+ chalk.bold("Status"),
213
+ chalk.bold("Count"),
214
+ chalk.bold("Tokens"),
215
+ ],
174
216
  style: { head: [], border: [] },
175
217
  });
176
218
  for (const d of detections) {
177
- const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
178
- const count = d.count ? fmtInt(d.count) : chalk.dim('—');
179
- const tok = d.hasTokens ? (d.count ? chalk.cyan('yes') : chalk.dim('—')) : chalk.dim('n/a');
219
+ const status =
220
+ d.status === "present"
221
+ ? chalk.green("● present")
222
+ : chalk.red("○ absent");
223
+ const count = d.count ? fmtInt(d.count) : chalk.dim("—");
224
+ const tok = d.hasTokens
225
+ ? d.count
226
+ ? chalk.cyan("yes")
227
+ : chalk.dim("—")
228
+ : chalk.dim("n/a");
180
229
  t.push([colorize(d.name, toolColor(d.key)), status, count, tok]);
181
230
  }
182
- return boxen(t.toString() + '\n' + chalk.dim('(paths hidden in narrow mode — use $AI_USAGE_PATHS_JSON to inspect)'),
183
- { title: chalk.bold('AI Tools Detected'), borderStyle: 'round', borderColor: 'cyan',
184
- padding: { top: 0, bottom: 0, left: 1, right: 1 } });
231
+ return boxen(
232
+ t.toString() +
233
+ "\n" +
234
+ chalk.dim(
235
+ "(paths hidden in narrow mode — use $AI_USAGE_PATHS_JSON to inspect)",
236
+ ),
237
+ {
238
+ title: chalk.bold("AI Tools Detected"),
239
+ borderStyle: "round",
240
+ borderColor: "cyan",
241
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
242
+ },
243
+ );
185
244
  }
186
245
 
187
246
  const t = new Table({
188
- head: [chalk.bold('Tool'), chalk.bold('Status'), chalk.bold('Path'), chalk.bold('Count'), chalk.bold('Tokens')],
247
+ head: [
248
+ chalk.bold("Tool"),
249
+ chalk.bold("Status"),
250
+ chalk.bold("Path"),
251
+ chalk.bold("Count"),
252
+ chalk.bold("Tokens"),
253
+ ],
189
254
  style: { head: [], border: [] },
190
255
  });
191
256
 
192
257
  for (const d of detections) {
193
- const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
194
- const path = d.path ? truncEnd(d.path.replace(process.env.HOME || '', '~'), 50) : chalk.dim('—');
195
- const count = d.count ? fmtInt(d.count) : chalk.dim('—');
258
+ const status =
259
+ d.status === "present" ? chalk.green("● present") : chalk.red("○ absent");
260
+ const path = d.path
261
+ ? truncEnd(d.path.replace(process.env.HOME || "", "~"), 50)
262
+ : chalk.dim("—");
263
+ const count = d.count ? fmtInt(d.count) : chalk.dim("—");
196
264
  const tok = d.hasTokens
197
- ? (d.count ? chalk.cyan('yes') : chalk.dim('—'))
198
- : chalk.dim('n/a');
265
+ ? d.count
266
+ ? chalk.cyan("yes")
267
+ : chalk.dim("—")
268
+ : chalk.dim("n/a");
199
269
  t.push([colorize(d.name, toolColor(d.key)), status, path, count, tok]);
200
270
  }
201
271
  return boxen(t.toString(), {
202
- title: chalk.bold('AI Tools Detected'),
203
- borderStyle: 'round',
204
- borderColor: 'cyan',
272
+ title: chalk.bold("AI Tools Detected"),
273
+ borderStyle: "round",
274
+ borderColor: "cyan",
205
275
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
206
276
  });
207
277
  }
@@ -216,23 +286,49 @@ export function renderOverview(records, detections) {
216
286
 
217
287
  // Token breakdown sub-table
218
288
  const bt = new Table({
219
- style: { border: ['gray'] },
289
+ style: { border: ["gray"] },
220
290
  colWidths: [14, 12, 12],
221
291
  });
222
292
  bt.push(
223
- [chalk.bold('Type'), chalk.bold('Tokens'), chalk.bold('Share')],
224
- [chalk.cyan('Input'), fmtCompact(breakdown.input), pct(breakdown.ratios.input)],
225
- [chalk.green('Output'), fmtCompact(breakdown.output), pct(breakdown.ratios.output)],
226
- [chalk.cyan('Cache Read'), fmtCompact(breakdown.cacheRead), pct(breakdown.ratios.cacheRead)],
227
- [chalk.cyan('Cache Write'), fmtCompact(breakdown.cacheWrite), pct(breakdown.ratios.cacheWrite)],
228
- [chalk.yellow('Reasoning'), fmtCompact(breakdown.reasoning), pct(breakdown.ratios.reasoning)],
229
- [chalk.bold('Total'), chalk.bold(fmtCompact(breakdown.total)), '100.0%'],
293
+ [chalk.bold("Type"), chalk.bold("Tokens"), chalk.bold("Share")],
294
+ [
295
+ chalk.cyan("Input"),
296
+ fmtCompact(breakdown.input),
297
+ pct(breakdown.ratios.input),
298
+ ],
299
+ [
300
+ chalk.green("Output"),
301
+ fmtCompact(breakdown.output),
302
+ pct(breakdown.ratios.output),
303
+ ],
304
+ [
305
+ chalk.cyan("Cache Read"),
306
+ fmtCompact(breakdown.cacheRead),
307
+ pct(breakdown.ratios.cacheRead),
308
+ ],
309
+ [
310
+ chalk.cyan("Cache Write"),
311
+ fmtCompact(breakdown.cacheWrite),
312
+ pct(breakdown.ratios.cacheWrite),
313
+ ],
314
+ [
315
+ chalk.yellow("Reasoning"),
316
+ fmtCompact(breakdown.reasoning),
317
+ pct(breakdown.ratios.reasoning),
318
+ ],
319
+ [chalk.bold("Total"), chalk.bold(fmtCompact(breakdown.total)), "100.0%"],
230
320
  );
231
321
 
232
322
  // Per-tool mini summary
233
323
  const pt = new Table({
234
- head: [chalk.bold('Tool'), chalk.bold('n'), chalk.bold('Total'), chalk.bold('Avg/sess'), chalk.bold('Cost')],
235
- style: { head: [], border: ['gray'] },
324
+ head: [
325
+ chalk.bold("Tool"),
326
+ chalk.bold("n"),
327
+ chalk.bold("Total"),
328
+ chalk.bold("Avg/sess"),
329
+ chalk.bold("Cost"),
330
+ ],
331
+ style: { head: [], border: ["gray"] },
236
332
  colWidths: [14, 5, 10, 11, 9],
237
333
  });
238
334
  const byTool = perTool(records);
@@ -246,45 +342,70 @@ export function renderOverview(records, detections) {
246
342
  ]);
247
343
  }
248
344
  pt.push([
249
- chalk.bold('TOTAL'),
345
+ chalk.bold("TOTAL"),
250
346
  chalk.bold(fmtInt(records.length)),
251
347
  chalk.bold(fmtCompact(t.tokensTotal)),
252
348
  chalk.bold(fmtCompact(t.avg)),
253
349
  chalk.bold(fmtCost(t.cost)),
254
350
  ]);
255
351
 
256
- const body = pt.toString() + '\n\n' + chalk.bold.underline('Token Breakdown') + '\n' + bt.toString();
352
+ const body =
353
+ pt.toString() +
354
+ "\n\n" +
355
+ chalk.bold.underline("Token Breakdown") +
356
+ "\n" +
357
+ bt.toString();
257
358
  return boxen(body, {
258
- title: chalk.bold('Overview'),
259
- borderStyle: 'round',
260
- borderColor: 'blue',
359
+ title: chalk.bold("Overview"),
360
+ borderStyle: "round",
361
+ borderColor: "blue",
261
362
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
262
363
  });
263
364
  }
264
365
 
265
- function pct(x) { return (x * 100).toFixed(1) + '%'; }
366
+ function pct(x) {
367
+ return (x * 100).toFixed(1) + "%";
368
+ }
266
369
 
267
370
  // ---------------------------------------------------------------------------
268
371
  // Per Project
269
372
  // ---------------------------------------------------------------------------
270
373
 
374
+ function fmtNByTool(nByTool) {
375
+ return Object.entries(nByTool)
376
+ .sort((a, b) => b[1] - a[1])
377
+ .map(([tool, count]) => chalk.hex(toolColor(tool))(`${tool}×${count}`))
378
+ .join(chalk.dim(" "));
379
+ }
380
+
271
381
  export function renderPerProject(records) {
272
382
  const items = perProject(records);
273
- if (items.length === 0) return '';
383
+ if (items.length === 0) return "";
274
384
  const barW = NARROW ? 10 : 18;
275
385
 
276
386
  // Narrow mode: drop In/Out/Cache columns, just show n + Total + Dist
277
387
  const head = NARROW
278
- ? [chalk.bold('Project'), chalk.bold('n'),
279
- chalk.bold('Total'), chalk.bold('Dist')]
280
- : [chalk.bold('Project'), chalk.bold('n'),
281
- chalk.bold('In'), chalk.bold('Out'),
282
- chalk.bold('Cache'), chalk.bold('Total'), chalk.bold('Dist')];
388
+ ? [
389
+ chalk.bold("Project"),
390
+ chalk.bold("n"),
391
+ chalk.bold("Total"),
392
+ chalk.bold("Dist"),
393
+ ]
394
+ : [
395
+ chalk.bold("Project"),
396
+ chalk.bold("Sessions by tool"),
397
+ chalk.bold("In"),
398
+ chalk.bold("Out"),
399
+ chalk.bold("Cache"),
400
+ chalk.bold("Total"),
401
+ chalk.bold("Dist"),
402
+ ];
283
403
  const t = new Table({ head, style: { head: [], border: [] } });
284
404
 
285
405
  for (const p of items) {
286
406
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
287
- const row = [truncEnd(p.project, NARROW ? 28 : 40), fmtInt(p.n)];
407
+ const sessionCell = NARROW ? fmtInt(p.n) : fmtNByTool(p.nByTool);
408
+ const row = [truncEnd(p.project, NARROW ? 28 : 40), sessionCell];
288
409
  if (!NARROW) {
289
410
  row.push(
290
411
  fmtCompact(p.tokensInput),
@@ -296,9 +417,141 @@ export function renderPerProject(records) {
296
417
  t.push(row);
297
418
  }
298
419
  return boxen(t.toString(), {
299
- title: chalk.bold('Per Project'),
300
- borderStyle: 'round',
301
- borderColor: 'cyan',
420
+ title: chalk.bold("Per Project"),
421
+ borderStyle: "round",
422
+ borderColor: "cyan",
423
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
424
+ });
425
+ }
426
+
427
+ // Cursor-aware variant used by the interactive TUI.
428
+ // The selected row is highlighted; a zero-width marker lets tui.js locate it.
429
+ const CURSOR_MARK = "\u2060";
430
+ export { CURSOR_MARK };
431
+
432
+ export function renderPerProjectCursor(records, cursorIdx) {
433
+ const items = perProject(records);
434
+ if (items.length === 0) return "";
435
+ const barW = NARROW ? 10 : 18;
436
+
437
+ const head = NARROW
438
+ ? [
439
+ chalk.bold("Project"),
440
+ chalk.bold("n"),
441
+ chalk.bold("Total"),
442
+ chalk.bold("Dist"),
443
+ ]
444
+ : [
445
+ chalk.bold("Project"),
446
+ chalk.bold("Sessions by tool"),
447
+ chalk.bold("In"),
448
+ chalk.bold("Out"),
449
+ chalk.bold("Cache"),
450
+ chalk.bold("Total"),
451
+ chalk.bold("Dist"),
452
+ ];
453
+ const t = new Table({ head, style: { head: [], border: [] } });
454
+
455
+ items.forEach((p, i) => {
456
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
457
+ const sessionCell = NARROW ? fmtInt(p.n) : fmtNByTool(p.nByTool);
458
+ const hl = i === cursorIdx;
459
+ const hi = (s) => (hl ? chalk.bgWhite.black(s) : s);
460
+ const projLabel =
461
+ (hl ? CURSOR_MARK : "") + truncEnd(p.project, NARROW ? 28 : 40);
462
+ const row = [
463
+ hl ? chalk.bgWhite.black(projLabel) : projLabel,
464
+ hi(sessionCell),
465
+ ];
466
+ if (!NARROW)
467
+ row.push(
468
+ hi(fmtCompact(p.tokensInput)),
469
+ hi(fmtCompact(p.tokensOutput)),
470
+ hi(fmtCompact(cache)),
471
+ );
472
+ row.push(
473
+ hi(fmtCompact(p.tokensTotal)),
474
+ hl ? hi(stackedBar(p.byTool, barW)) : stackedBar(p.byTool, barW),
475
+ );
476
+ t.push(row);
477
+ });
478
+
479
+ return boxen(t.toString(), {
480
+ title:
481
+ chalk.bold("Per Project") + chalk.dim(" ↑/↓ navigate · Enter select"),
482
+ borderStyle: "round",
483
+ borderColor: "cyan",
484
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
485
+ });
486
+ }
487
+
488
+ export function renderProjectSessions(records, projectName) {
489
+ const sessions = records
490
+ .filter((r) => r.project === projectName)
491
+ .sort((a, b) => b.ts - a.ts);
492
+
493
+ if (sessions.length === 0) return chalk.dim(" (no sessions)");
494
+
495
+ const head = NARROW
496
+ ? [
497
+ chalk.bold("#"),
498
+ chalk.bold("Tool"),
499
+ chalk.bold("Dur"),
500
+ chalk.bold("Total"),
501
+ chalk.bold("Cost"),
502
+ chalk.bold("Title"),
503
+ ]
504
+ : [
505
+ chalk.bold("#"),
506
+ chalk.bold("Date"),
507
+ chalk.bold("Tool"),
508
+ chalk.bold("Duration"),
509
+ chalk.bold("Total"),
510
+ chalk.bold("In"),
511
+ chalk.bold("Out"),
512
+ chalk.bold("Cache"),
513
+ chalk.bold("Cost"),
514
+ chalk.bold("Title"),
515
+ ];
516
+ const t = new Table({ head, style: { head: [], border: [] } });
517
+
518
+ sessions.forEach((r, i) => {
519
+ const date = r.ts ? new Date(r.ts).toISOString().slice(0, 10) : "—";
520
+ const cache = (r.tokensCacheRead || 0) + (r.tokensCacheWrite || 0);
521
+ const dur = fmtDuration(r.durationMs);
522
+ const titleStr = truncEnd(r.title || chalk.dim("—"), NARROW ? 28 : 42);
523
+ if (NARROW) {
524
+ t.push([
525
+ String(i + 1),
526
+ colorize(r.tool, toolColor(r.tool)),
527
+ dur,
528
+ fmtCompact(r.tokensTotal),
529
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
530
+ titleStr,
531
+ ]);
532
+ } else {
533
+ t.push([
534
+ String(i + 1),
535
+ date,
536
+ colorize(r.tool, toolColor(r.tool)),
537
+ dur,
538
+ chalk.bold(fmtCompact(r.tokensTotal)),
539
+ fmtCompact(r.tokensInput),
540
+ fmtCompact(r.tokensOutput),
541
+ fmtCompact(cache),
542
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
543
+ titleStr,
544
+ ]);
545
+ }
546
+ });
547
+
548
+ return boxen(t.toString(), {
549
+ title:
550
+ chalk.bold("Sessions ") +
551
+ chalk.cyan(truncEnd(projectName, 60)) +
552
+ chalk.dim(" Esc back"),
553
+ borderStyle: "round",
554
+ borderColor: "yellow",
302
555
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
303
556
  });
304
557
  }
@@ -309,24 +562,35 @@ export function renderPerProject(records) {
309
562
 
310
563
  export function renderPerTool(records) {
311
564
  const items = perTool(records);
312
- if (items.length === 0) return '';
565
+ if (items.length === 0) return "";
313
566
  const max = items[0].tokensTotal;
314
567
  const barW = NARROW ? 10 : 18;
315
568
 
316
569
  const head = NARROW
317
- ? [chalk.bold('Tool'), chalk.bold('n'),
318
- chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')]
319
- : [chalk.bold('Tool'), chalk.bold('n'),
320
- chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
321
- chalk.bold('Total'), chalk.bold('Cost'), chalk.bold('Avg/sess'), chalk.bold('Dist')];
570
+ ? [
571
+ chalk.bold("Tool"),
572
+ chalk.bold("n"),
573
+ chalk.bold("Total"),
574
+ chalk.bold("Cost"),
575
+ chalk.bold("Avg/sess"),
576
+ chalk.bold("Dist"),
577
+ ]
578
+ : [
579
+ chalk.bold("Tool"),
580
+ chalk.bold("n"),
581
+ chalk.bold("Input"),
582
+ chalk.bold("Output"),
583
+ chalk.bold("Cache"),
584
+ chalk.bold("Total"),
585
+ chalk.bold("Cost"),
586
+ chalk.bold("Avg/sess"),
587
+ chalk.bold("Dist"),
588
+ ];
322
589
  const t = new Table({ head, style: { head: [], border: [] } });
323
590
 
324
591
  for (const p of items) {
325
592
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
326
- const row = [
327
- colorize(p.tool, toolColor(p.tool)),
328
- fmtInt(p.n),
329
- ];
593
+ const row = [colorize(p.tool, toolColor(p.tool)), fmtInt(p.n)];
330
594
  if (!NARROW) {
331
595
  row.push(
332
596
  fmtCompact(p.tokensInput),
@@ -343,9 +607,9 @@ export function renderPerTool(records) {
343
607
  t.push(row);
344
608
  }
345
609
  return boxen(t.toString(), {
346
- title: chalk.bold('Per Tool'),
347
- borderStyle: 'round',
348
- borderColor: 'magenta',
610
+ title: chalk.bold("Per Tool"),
611
+ borderStyle: "round",
612
+ borderColor: "magenta",
349
613
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
350
614
  });
351
615
  }
@@ -356,16 +620,28 @@ export function renderPerTool(records) {
356
620
 
357
621
  export function renderPerToolPerMonth(records) {
358
622
  const items = perToolPerMonth(records);
359
- if (items.length === 0) return '';
623
+ if (items.length === 0) return "";
360
624
  const max = items[0].tokensTotal;
361
625
  const barW = NARROW ? 8 : 16;
362
626
 
363
627
  const head = NARROW
364
- ? [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
365
- chalk.bold('Total'), chalk.bold('Dist')]
366
- : [chalk.bold('Tool'), chalk.bold('Month'), chalk.bold('n'),
367
- chalk.bold('Input'), chalk.bold('Output'), chalk.bold('Cache'),
368
- chalk.bold('Total'), chalk.bold('Dist')];
628
+ ? [
629
+ chalk.bold("Tool"),
630
+ chalk.bold("Month"),
631
+ chalk.bold("n"),
632
+ chalk.bold("Total"),
633
+ chalk.bold("Dist"),
634
+ ]
635
+ : [
636
+ chalk.bold("Tool"),
637
+ chalk.bold("Month"),
638
+ chalk.bold("n"),
639
+ chalk.bold("Input"),
640
+ chalk.bold("Output"),
641
+ chalk.bold("Cache"),
642
+ chalk.bold("Total"),
643
+ chalk.bold("Dist"),
644
+ ];
369
645
  const t = new Table({ head, style: { head: [], border: [] } });
370
646
 
371
647
  for (const p of items) {
@@ -385,16 +661,13 @@ export function renderPerToolPerMonth(records) {
385
661
  fmtCompact(cache),
386
662
  );
387
663
  }
388
- row.push(
389
- fmtCompact(p.tokensTotal),
390
- bar(p.tokensTotal, max, barW),
391
- );
664
+ row.push(fmtCompact(p.tokensTotal), bar(p.tokensTotal, max, barW));
392
665
  t.push(row);
393
666
  }
394
667
  return boxen(t.toString(), {
395
- title: chalk.bold('Per Tool per Month'),
396
- borderStyle: 'round',
397
- borderColor: 'cyan',
668
+ title: chalk.bold("Per Tool per Month"),
669
+ borderStyle: "round",
670
+ borderColor: "cyan",
398
671
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
399
672
  });
400
673
  }
@@ -405,13 +678,16 @@ export function renderPerToolPerMonth(records) {
405
678
 
406
679
  export function renderPerMonth(records) {
407
680
  const items = perMonth(records);
408
- if (items.length === 0) return '';
681
+ if (items.length === 0) return "";
409
682
  const barW = NARROW ? 10 : 16;
410
683
 
411
684
  const head = [
412
- chalk.bold('Month'), chalk.bold('n'),
413
- chalk.bold('Input'), chalk.bold('Output'),
414
- chalk.bold('Total'), chalk.bold('Dist'),
685
+ chalk.bold("Month"),
686
+ chalk.bold("n"),
687
+ chalk.bold("Input"),
688
+ chalk.bold("Output"),
689
+ chalk.bold("Total"),
690
+ chalk.bold("Dist"),
415
691
  ];
416
692
  const t = new Table({ head, style: { head: [], border: [] } });
417
693
 
@@ -432,18 +708,18 @@ export function renderPerMonth(records) {
432
708
  // TOTAL row
433
709
  const tot = overall(records);
434
710
  t.push([
435
- chalk.bgGray.white.bold(' TOTAL '),
711
+ chalk.bgGray.white.bold(" TOTAL "),
436
712
  chalk.bgGray.white.bold(fmtInt(records.length)),
437
713
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
438
714
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
439
715
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
440
- '',
716
+ "",
441
717
  ]);
442
718
 
443
719
  return boxen(t.toString(), {
444
- title: chalk.bold('Per Month'),
445
- borderStyle: 'round',
446
- borderColor: 'green',
720
+ title: chalk.bold("Per Month"),
721
+ borderStyle: "round",
722
+ borderColor: "green",
447
723
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
448
724
  });
449
725
  }
@@ -454,13 +730,16 @@ export function renderPerMonth(records) {
454
730
 
455
731
  export function renderPerWeek(records) {
456
732
  const items = perWeek(records);
457
- if (items.length === 0) return '';
733
+ if (items.length === 0) return "";
458
734
  const barW = NARROW ? 10 : 14;
459
735
 
460
736
  const head = [
461
- chalk.bold('ISO Week'), chalk.bold('n'),
462
- chalk.bold('Input'), chalk.bold('Output'),
463
- chalk.bold('Total'), chalk.bold('Dist'),
737
+ chalk.bold("ISO Week"),
738
+ chalk.bold("n"),
739
+ chalk.bold("Input"),
740
+ chalk.bold("Output"),
741
+ chalk.bold("Total"),
742
+ chalk.bold("Dist"),
464
743
  ];
465
744
  const t = new Table({ head, style: { head: [], border: [] } });
466
745
 
@@ -477,18 +756,18 @@ export function renderPerWeek(records) {
477
756
  // TOTAL row
478
757
  const tot = overall(records);
479
758
  t.push([
480
- chalk.bgGray.white.bold(' TOTAL '),
759
+ chalk.bgGray.white.bold(" TOTAL "),
481
760
  chalk.bgGray.white.bold(fmtInt(records.length)),
482
761
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
483
762
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
484
763
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
485
- '',
764
+ "",
486
765
  ]);
487
766
 
488
767
  return boxen(t.toString(), {
489
- title: chalk.bold('Per Week'),
490
- borderStyle: 'round',
491
- borderColor: 'magenta',
768
+ title: chalk.bold("Per Week"),
769
+ borderStyle: "round",
770
+ borderColor: "magenta",
492
771
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
493
772
  });
494
773
  }
@@ -499,12 +778,17 @@ export function renderPerWeek(records) {
499
778
 
500
779
  export function renderTopSessions(records, n = 5) {
501
780
  const top = topSessions(records, n);
502
- if (top.length === 0) return '';
781
+ if (top.length === 0) return "";
503
782
 
504
783
  if (NARROW) {
505
784
  const t = new Table({
506
- head: [chalk.bold('#'), chalk.bold('Total'), chalk.bold('In/Out'),
507
- chalk.bold('Cost'), chalk.bold('Title')],
785
+ head: [
786
+ chalk.bold("#"),
787
+ chalk.bold("Total"),
788
+ chalk.bold("In/Out"),
789
+ chalk.bold("Cost"),
790
+ chalk.bold("Title"),
791
+ ],
508
792
  style: { head: [], border: [] },
509
793
  });
510
794
  top.forEach((r, i) => {
@@ -512,22 +796,31 @@ export function renderTopSessions(records, n = 5) {
512
796
  chalk.bold(String(i + 1)),
513
797
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
514
798
  `${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
515
- r.cost ? '$' + r.cost.toFixed(4) : '—',
516
- truncEnd(r.title, 30) || '—',
799
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
800
+ truncEnd(r.title, 30) || "—",
517
801
  ]);
518
802
  });
519
- return boxen(t.toString() + '\n' + chalk.dim('(full project + model in wide mode)'),
520
- { title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
521
- borderStyle: 'round', borderColor: 'yellow',
522
- padding: { top: 0, bottom: 0, left: 1, right: 1 } });
803
+ return boxen(
804
+ t.toString() + "\n" + chalk.dim("(full project + model in wide mode)"),
805
+ {
806
+ title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
807
+ borderStyle: "round",
808
+ borderColor: "yellow",
809
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
810
+ },
811
+ );
523
812
  }
524
813
 
525
814
  const t = new Table({
526
815
  head: [
527
- chalk.bold('#'), chalk.bold('Total'),
528
- chalk.bold('Input'), chalk.bold('Output'),
529
- chalk.bold('Cost'), chalk.bold('Model'),
530
- chalk.bold('Project'), chalk.bold('Title'),
816
+ chalk.bold("#"),
817
+ chalk.bold("Total"),
818
+ chalk.bold("Input"),
819
+ chalk.bold("Output"),
820
+ chalk.bold("Cost"),
821
+ chalk.bold("Model"),
822
+ chalk.bold("Project"),
823
+ chalk.bold("Title"),
531
824
  ],
532
825
  style: { head: [], border: [] },
533
826
  });
@@ -537,16 +830,16 @@ export function renderTopSessions(records, n = 5) {
537
830
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
538
831
  fmtCompact(r.tokensInput),
539
832
  fmtCompact(r.tokensOutput),
540
- r.cost ? '$' + r.cost.toFixed(4) : '—',
833
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
541
834
  shortModel(r.model),
542
835
  truncEnd(r.project, 30),
543
- truncEnd(r.title, 40) || '—',
836
+ truncEnd(r.title, 40) || "—",
544
837
  ]);
545
838
  });
546
839
  return boxen(t.toString(), {
547
840
  title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
548
- borderStyle: 'round',
549
- borderColor: 'yellow',
841
+ borderStyle: "round",
842
+ borderColor: "yellow",
550
843
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
551
844
  });
552
845
  }
@@ -557,53 +850,78 @@ export function renderTopSessions(records, n = 5) {
557
850
 
558
851
  export function renderNotes(detections, errors) {
559
852
  const lines = [];
560
- lines.push(chalk.bold.underline('Token definitions'));
561
- lines.push(` • ${chalk.cyan('Input')} — prompt tokens (what was sent to the model)`);
562
- lines.push(` • ${chalk.green('Output')} — completion tokens (what the model generated)`);
563
- lines.push(` • ${chalk.cyan('Cache Read')} — prompt tokens served from provider cache (cheap)`);
564
- lines.push(` • ${chalk.cyan('Cache Write')} — prompt tokens cached for future use (OpenCode only)`);
565
- lines.push(` • ${chalk.yellow('Reasoning')} — extended thinking / chain-of-thought tokens`);
566
-
567
- lines.push('');
568
- lines.push(chalk.bold.underline('Detected tools with token data'));
569
- const withTokens = detections.filter(d => d.hasTokens && d.status === 'present');
853
+ lines.push(chalk.bold.underline("Token definitions"));
854
+ lines.push(
855
+ ` • ${chalk.cyan("Input")} — prompt tokens (what was sent to the model)`,
856
+ );
857
+ lines.push(
858
+ ` • ${chalk.green("Output")} — completion tokens (what the model generated)`,
859
+ );
860
+ lines.push(
861
+ ` • ${chalk.cyan("Cache Read")} — prompt tokens served from provider cache (cheap)`,
862
+ );
863
+ lines.push(
864
+ ` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use (OpenCode only)`,
865
+ );
866
+ lines.push(
867
+ ` • ${chalk.yellow("Reasoning")} — extended thinking / chain-of-thought tokens`,
868
+ );
869
+
870
+ lines.push("");
871
+ lines.push(chalk.bold.underline("Detected tools with token data"));
872
+ const withTokens = detections.filter(
873
+ (d) => d.hasTokens && d.status === "present",
874
+ );
570
875
  if (withTokens.length === 0) {
571
- lines.push(' ' + chalk.dim('(none — only presence info available)'));
876
+ lines.push(" " + chalk.dim("(none — only presence info available)"));
572
877
  } else {
573
878
  for (const d of withTokens) {
574
- lines.push(` ${chalk.green('●')} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`);
879
+ lines.push(
880
+ ` ${chalk.green("●")} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`,
881
+ );
575
882
  }
576
883
  }
577
884
 
578
- lines.push('');
579
- lines.push(chalk.bold.underline('Detected tools without token data (presence only)'));
580
- const noTokens = detections.filter(d => !d.hasTokens && d.status === 'present');
885
+ lines.push("");
886
+ lines.push(
887
+ chalk.bold.underline("Detected tools without token data (presence only)"),
888
+ );
889
+ const noTokens = detections.filter(
890
+ (d) => !d.hasTokens && d.status === "present",
891
+ );
581
892
  if (noTokens.length === 0) {
582
- lines.push(' ' + chalk.dim('(none)'));
893
+ lines.push(" " + chalk.dim("(none)"));
583
894
  } else {
584
895
  for (const d of noTokens) {
585
- lines.push(` ${chalk.green('●')} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`);
896
+ lines.push(
897
+ ` ${chalk.green("●")} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`,
898
+ );
586
899
  }
587
900
  }
588
901
 
589
- lines.push('');
590
- lines.push(chalk.bold.underline('Env overrides'));
591
- lines.push(' $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME $MIMOCODE_HOME');
592
- lines.push(' $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME');
593
- lines.push(' $AI_USAGE_PATHS_JSON = ' + chalk.italic('\'{"codex":"/custom/path"}\''));
902
+ lines.push("");
903
+ lines.push(chalk.bold.underline("Env overrides"));
904
+ lines.push(
905
+ " $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME $MIMOCODE_HOME",
906
+ );
907
+ lines.push(" $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME");
908
+ lines.push(
909
+ " $AI_USAGE_PATHS_JSON = " + chalk.italic('\'{"codex":"/custom/path"}\''),
910
+ );
594
911
 
595
912
  if (errors && errors.length) {
596
- lines.push('');
913
+ lines.push("");
597
914
  lines.push(chalk.bold.underline(chalk.red(`Errors (${errors.length})`)));
598
915
  for (const e of errors.slice(0, 5)) {
599
- lines.push(' ' + chalk.red('! ') + e);
916
+ lines.push(" " + chalk.red("! ") + e);
600
917
  }
601
- if (errors.length > 5) lines.push(' ' + chalk.dim(`... and ${errors.length - 5} more`));
918
+ if (errors.length > 5)
919
+ lines.push(" " + chalk.dim(`... and ${errors.length - 5} more`));
602
920
  }
603
921
 
604
- return boxen(lines.join('\n'), {
605
- borderStyle: 'round',
606
- borderColor: 'gray',
922
+ return boxen(lines.join("\n"), {
923
+ borderStyle: "round",
924
+ borderColor: "gray",
607
925
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
608
926
  });
609
927
  }