ai-usage-analyzer 0.3.1 → 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,15 +1,32 @@
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';
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";
8
11
  import {
9
- perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
10
- MONTH_NAMES,
11
- } from './aggregate.js';
12
- 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";
23
+
24
+ const VERSION = JSON.parse(
25
+ readFileSync(
26
+ join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
27
+ "utf8",
28
+ ),
29
+ ).version;
13
30
 
14
31
  // ---------------------------------------------------------------------------
15
32
  // Terminal width detection
@@ -27,8 +44,8 @@ function detectWidth() {
27
44
  }
28
45
  // 3. tput cols (POSIX)
29
46
  try {
30
- const { execSync } = require('node:child_process');
31
- 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" });
32
49
  const n = parseInt(out.trim(), 10);
33
50
  if (Number.isFinite(n) && n > 0) return n;
34
51
  } catch {}
@@ -44,39 +61,53 @@ export const NARROW = TERM_WIDTH < 110;
44
61
  // ---------------------------------------------------------------------------
45
62
 
46
63
  export function fmtInt(n) {
47
- return Number(n).toLocaleString('en-US');
64
+ return Number(n).toLocaleString("en-US");
48
65
  }
49
66
 
50
67
  export function fmtCompact(n) {
51
68
  const a = Math.abs(n);
52
- if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
53
- if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
54
- 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";
55
72
  return String(n);
56
73
  }
57
74
 
58
75
  export function fmtCost(n) {
59
- if (!n) return '—';
60
- 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`;
61
89
  }
62
-
63
90
  // ---------------------------------------------------------------------------
64
91
  // Color helpers
65
92
  // ---------------------------------------------------------------------------
66
93
 
67
- export function toolColor(t) { return getToolColor(t); }
68
- 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
+ }
69
100
 
70
101
  // Cool-to-warm gradient: small bars feel subtle, large bars pop.
71
- const BAR_GRADIENT = gradient(['#3b82f6', '#14b8a6', '#f1fa8c', '#ff9e64']);
102
+ const BAR_GRADIENT = gradient(["#3b82f6", "#14b8a6", "#f1fa8c", "#ff9e64"]);
72
103
 
73
104
  function bar(value, max, width) {
74
- if (max <= 0) return ' '.repeat(width);
105
+ if (max <= 0) return " ".repeat(width);
75
106
  const pct = Math.max(0, Math.min(1, value / max));
76
107
  const filled = Math.round(pct * width);
77
108
  const empty = width - filled;
78
- if (filled === 0) return chalk.gray('░'.repeat(width));
79
- 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));
80
111
  }
81
112
 
82
113
  // Stacked bar — one segment per tool, widths proportional to that tool's
@@ -85,71 +116,86 @@ function bar(value, max, width) {
85
116
  // colored segment; no change in feel.
86
117
  function stackedBar(byTool, width) {
87
118
  const total = Object.values(byTool).reduce((a, b) => a + b, 0);
88
- if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
119
+ if (total <= 0 || width <= 0) return " ".repeat(Math.max(0, width));
89
120
  const entries = Object.entries(byTool)
90
121
  .filter(([, v]) => v > 0)
91
122
  .sort((a, b) => b[1] - a[1]);
92
- let out = '';
123
+ let out = "";
93
124
  let x = 0;
94
125
  for (let i = 0; i < entries.length; i++) {
95
126
  const [tool, value] = entries[i];
96
127
  // Last segment fills the remaining width so rounding doesn't leave
97
128
  // a visible gap or overshoot.
98
- const segW = i === entries.length - 1
99
- ? width - x
100
- : Math.round((value / total) * width);
129
+ const segW =
130
+ i === entries.length - 1
131
+ ? width - x
132
+ : Math.round((value / total) * width);
101
133
  if (segW <= 0) continue;
102
- out += chalk.hex(toolColor(tool))('█'.repeat(segW));
134
+ out += chalk.hex(toolColor(tool))("█".repeat(segW));
103
135
  x += segW;
104
136
  }
105
137
  return out;
106
138
  }
107
139
 
108
140
  function shortModel(m) {
109
- if (!m) return '—';
110
- if (typeof m === 'string' && m.trim().startsWith('{')) {
141
+ if (!m) return "—";
142
+ if (typeof m === "string" && m.trim().startsWith("{")) {
111
143
  try {
112
144
  const d = JSON.parse(m);
113
145
  const id = d.id || d.model || m;
114
146
  const prov = d.providerId || d.provider;
115
- return id + (prov ? ` (${prov})` : '');
116
- } catch { return m.slice(0, 30); }
147
+ return id + (prov ? ` (${prov})` : "");
148
+ } catch {
149
+ return m.slice(0, 30);
150
+ }
117
151
  }
118
152
  return m;
119
153
  }
120
154
 
121
155
  function truncMiddle(s, w) {
122
- if (!s) return '';
156
+ if (!s) return "";
123
157
  if (s.length <= w) return s;
124
158
  if (w <= 1) return s.slice(0, w);
125
159
  const head = Math.ceil((w - 1) / 2);
126
160
  const tail = Math.floor((w - 1) / 2);
127
- return s.slice(0, head) + '…' + s.slice(s.length - tail);
161
+ return s.slice(0, head) + "…" + s.slice(s.length - tail);
128
162
  }
129
163
 
130
164
  function truncEnd(s, w) {
131
- if (!s) return '';
165
+ if (!s) return "";
132
166
  if (s.length <= w) return s;
133
- return s.slice(0, w - 1) + '…';
167
+ return s.slice(0, w - 1) + "…";
134
168
  }
135
169
 
136
170
  // ---------------------------------------------------------------------------
137
171
  // Header
138
172
  // ---------------------------------------------------------------------------
139
173
 
140
- export function renderHeader({ totalSessions, totalTokens, totalCost, dateRange }) {
141
- const title = gradient.pastel.multiline('◆ AI TOKEN ANALYZER ◆');
142
- const sub = chalk.dim(`${totalSessions} sessions`) + ' • ' +
143
- chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
144
- (totalCost ? ' • ' + chalk.bold.yellow(`$${totalCost.toFixed(2)} USD`) : '');
145
- const range = (dateRange[0] && dateRange[1])
146
- ? chalk.dim.italic(`\n range: ${dateRange[0]} → ${dateRange[1]}`)
147
- : '';
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
+ : "";
148
192
  return boxen(`${title}\n\n${sub}${range}`, {
149
- borderStyle: 'round',
150
- borderColor: 'magenta',
193
+ title: chalk.dim(`v${VERSION}`),
194
+ titleAlignment: "right",
195
+ borderStyle: "round",
196
+ borderColor: "magenta",
151
197
  padding: { top: 0, bottom: 0, left: 2, right: 2 },
152
- align: 'center',
198
+ align: "center",
153
199
  });
154
200
  }
155
201
 
@@ -161,38 +207,71 @@ export function renderDetections(detections) {
161
207
  // For narrow terminals, simplify to Tool + Status + Count + Tokens (drop Path)
162
208
  if (NARROW) {
163
209
  const t = new Table({
164
- 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
+ ],
165
216
  style: { head: [], border: [] },
166
217
  });
167
218
  for (const d of detections) {
168
- const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
169
- const count = d.count ? fmtInt(d.count) : chalk.dim('—');
170
- 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");
171
229
  t.push([colorize(d.name, toolColor(d.key)), status, count, tok]);
172
230
  }
173
- return boxen(t.toString() + '\n' + chalk.dim('(paths hidden in narrow mode — use $AI_USAGE_PATHS_JSON to inspect)'),
174
- { title: chalk.bold('AI Tools Detected'), borderStyle: 'round', borderColor: 'cyan',
175
- 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
+ );
176
244
  }
177
245
 
178
246
  const t = new Table({
179
- 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
+ ],
180
254
  style: { head: [], border: [] },
181
255
  });
182
256
 
183
257
  for (const d of detections) {
184
- const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
185
- const path = d.path ? truncEnd(d.path.replace(process.env.HOME || '', '~'), 50) : chalk.dim('—');
186
- 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("—");
187
264
  const tok = d.hasTokens
188
- ? (d.count ? chalk.cyan('yes') : chalk.dim('—'))
189
- : chalk.dim('n/a');
265
+ ? d.count
266
+ ? chalk.cyan("yes")
267
+ : chalk.dim("—")
268
+ : chalk.dim("n/a");
190
269
  t.push([colorize(d.name, toolColor(d.key)), status, path, count, tok]);
191
270
  }
192
271
  return boxen(t.toString(), {
193
- title: chalk.bold('AI Tools Detected'),
194
- borderStyle: 'round',
195
- borderColor: 'cyan',
272
+ title: chalk.bold("AI Tools Detected"),
273
+ borderStyle: "round",
274
+ borderColor: "cyan",
196
275
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
197
276
  });
198
277
  }
@@ -207,23 +286,49 @@ export function renderOverview(records, detections) {
207
286
 
208
287
  // Token breakdown sub-table
209
288
  const bt = new Table({
210
- style: { border: ['gray'] },
289
+ style: { border: ["gray"] },
211
290
  colWidths: [14, 12, 12],
212
291
  });
213
292
  bt.push(
214
- [chalk.bold('Type'), chalk.bold('Tokens'), chalk.bold('Share')],
215
- [chalk.cyan('Input'), fmtCompact(breakdown.input), pct(breakdown.ratios.input)],
216
- [chalk.green('Output'), fmtCompact(breakdown.output), pct(breakdown.ratios.output)],
217
- [chalk.cyan('Cache Read'), fmtCompact(breakdown.cacheRead), pct(breakdown.ratios.cacheRead)],
218
- [chalk.cyan('Cache Write'), fmtCompact(breakdown.cacheWrite), pct(breakdown.ratios.cacheWrite)],
219
- [chalk.yellow('Reasoning'), fmtCompact(breakdown.reasoning), pct(breakdown.ratios.reasoning)],
220
- [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%"],
221
320
  );
222
321
 
223
322
  // Per-tool mini summary
224
323
  const pt = new Table({
225
- head: [chalk.bold('Tool'), chalk.bold('n'), chalk.bold('Total'), chalk.bold('Avg/sess'), chalk.bold('Cost')],
226
- 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"] },
227
332
  colWidths: [14, 5, 10, 11, 9],
228
333
  });
229
334
  const byTool = perTool(records);
@@ -237,45 +342,70 @@ export function renderOverview(records, detections) {
237
342
  ]);
238
343
  }
239
344
  pt.push([
240
- chalk.bold('TOTAL'),
345
+ chalk.bold("TOTAL"),
241
346
  chalk.bold(fmtInt(records.length)),
242
347
  chalk.bold(fmtCompact(t.tokensTotal)),
243
348
  chalk.bold(fmtCompact(t.avg)),
244
349
  chalk.bold(fmtCost(t.cost)),
245
350
  ]);
246
351
 
247
- 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();
248
358
  return boxen(body, {
249
- title: chalk.bold('Overview'),
250
- borderStyle: 'round',
251
- borderColor: 'blue',
359
+ title: chalk.bold("Overview"),
360
+ borderStyle: "round",
361
+ borderColor: "blue",
252
362
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
253
363
  });
254
364
  }
255
365
 
256
- function pct(x) { return (x * 100).toFixed(1) + '%'; }
366
+ function pct(x) {
367
+ return (x * 100).toFixed(1) + "%";
368
+ }
257
369
 
258
370
  // ---------------------------------------------------------------------------
259
371
  // Per Project
260
372
  // ---------------------------------------------------------------------------
261
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
+
262
381
  export function renderPerProject(records) {
263
382
  const items = perProject(records);
264
- if (items.length === 0) return '';
383
+ if (items.length === 0) return "";
265
384
  const barW = NARROW ? 10 : 18;
266
385
 
267
386
  // Narrow mode: drop In/Out/Cache columns, just show n + Total + Dist
268
387
  const head = NARROW
269
- ? [chalk.bold('Project'), chalk.bold('n'),
270
- chalk.bold('Total'), chalk.bold('Dist')]
271
- : [chalk.bold('Project'), chalk.bold('n'),
272
- chalk.bold('In'), chalk.bold('Out'),
273
- 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
+ ];
274
403
  const t = new Table({ head, style: { head: [], border: [] } });
275
404
 
276
405
  for (const p of items) {
277
406
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
278
- 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];
279
409
  if (!NARROW) {
280
410
  row.push(
281
411
  fmtCompact(p.tokensInput),
@@ -287,9 +417,141 @@ export function renderPerProject(records) {
287
417
  t.push(row);
288
418
  }
289
419
  return boxen(t.toString(), {
290
- title: chalk.bold('Per Project'),
291
- borderStyle: 'round',
292
- 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",
293
555
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
294
556
  });
295
557
  }
@@ -300,24 +562,35 @@ export function renderPerProject(records) {
300
562
 
301
563
  export function renderPerTool(records) {
302
564
  const items = perTool(records);
303
- if (items.length === 0) return '';
565
+ if (items.length === 0) return "";
304
566
  const max = items[0].tokensTotal;
305
567
  const barW = NARROW ? 10 : 18;
306
568
 
307
569
  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')];
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
+ ];
313
589
  const t = new Table({ head, style: { head: [], border: [] } });
314
590
 
315
591
  for (const p of items) {
316
592
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
317
- const row = [
318
- colorize(p.tool, toolColor(p.tool)),
319
- fmtInt(p.n),
320
- ];
593
+ const row = [colorize(p.tool, toolColor(p.tool)), fmtInt(p.n)];
321
594
  if (!NARROW) {
322
595
  row.push(
323
596
  fmtCompact(p.tokensInput),
@@ -334,9 +607,9 @@ export function renderPerTool(records) {
334
607
  t.push(row);
335
608
  }
336
609
  return boxen(t.toString(), {
337
- title: chalk.bold('Per Tool'),
338
- borderStyle: 'round',
339
- borderColor: 'magenta',
610
+ title: chalk.bold("Per Tool"),
611
+ borderStyle: "round",
612
+ borderColor: "magenta",
340
613
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
341
614
  });
342
615
  }
@@ -347,16 +620,28 @@ export function renderPerTool(records) {
347
620
 
348
621
  export function renderPerToolPerMonth(records) {
349
622
  const items = perToolPerMonth(records);
350
- if (items.length === 0) return '';
623
+ if (items.length === 0) return "";
351
624
  const max = items[0].tokensTotal;
352
625
  const barW = NARROW ? 8 : 16;
353
626
 
354
627
  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')];
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
+ ];
360
645
  const t = new Table({ head, style: { head: [], border: [] } });
361
646
 
362
647
  for (const p of items) {
@@ -376,16 +661,13 @@ export function renderPerToolPerMonth(records) {
376
661
  fmtCompact(cache),
377
662
  );
378
663
  }
379
- row.push(
380
- fmtCompact(p.tokensTotal),
381
- bar(p.tokensTotal, max, barW),
382
- );
664
+ row.push(fmtCompact(p.tokensTotal), bar(p.tokensTotal, max, barW));
383
665
  t.push(row);
384
666
  }
385
667
  return boxen(t.toString(), {
386
- title: chalk.bold('Per Tool per Month'),
387
- borderStyle: 'round',
388
- borderColor: 'cyan',
668
+ title: chalk.bold("Per Tool per Month"),
669
+ borderStyle: "round",
670
+ borderColor: "cyan",
389
671
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
390
672
  });
391
673
  }
@@ -396,13 +678,16 @@ export function renderPerToolPerMonth(records) {
396
678
 
397
679
  export function renderPerMonth(records) {
398
680
  const items = perMonth(records);
399
- if (items.length === 0) return '';
681
+ if (items.length === 0) return "";
400
682
  const barW = NARROW ? 10 : 16;
401
683
 
402
684
  const head = [
403
- chalk.bold('Month'), chalk.bold('n'),
404
- chalk.bold('Input'), chalk.bold('Output'),
405
- 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"),
406
691
  ];
407
692
  const t = new Table({ head, style: { head: [], border: [] } });
408
693
 
@@ -423,18 +708,18 @@ export function renderPerMonth(records) {
423
708
  // TOTAL row
424
709
  const tot = overall(records);
425
710
  t.push([
426
- chalk.bgGray.white.bold(' TOTAL '),
711
+ chalk.bgGray.white.bold(" TOTAL "),
427
712
  chalk.bgGray.white.bold(fmtInt(records.length)),
428
713
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
429
714
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
430
715
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
431
- '',
716
+ "",
432
717
  ]);
433
718
 
434
719
  return boxen(t.toString(), {
435
- title: chalk.bold('Per Month'),
436
- borderStyle: 'round',
437
- borderColor: 'green',
720
+ title: chalk.bold("Per Month"),
721
+ borderStyle: "round",
722
+ borderColor: "green",
438
723
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
439
724
  });
440
725
  }
@@ -445,13 +730,16 @@ export function renderPerMonth(records) {
445
730
 
446
731
  export function renderPerWeek(records) {
447
732
  const items = perWeek(records);
448
- if (items.length === 0) return '';
733
+ if (items.length === 0) return "";
449
734
  const barW = NARROW ? 10 : 14;
450
735
 
451
736
  const head = [
452
- chalk.bold('ISO Week'), chalk.bold('n'),
453
- chalk.bold('Input'), chalk.bold('Output'),
454
- 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"),
455
743
  ];
456
744
  const t = new Table({ head, style: { head: [], border: [] } });
457
745
 
@@ -468,18 +756,18 @@ export function renderPerWeek(records) {
468
756
  // TOTAL row
469
757
  const tot = overall(records);
470
758
  t.push([
471
- chalk.bgGray.white.bold(' TOTAL '),
759
+ chalk.bgGray.white.bold(" TOTAL "),
472
760
  chalk.bgGray.white.bold(fmtInt(records.length)),
473
761
  chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
474
762
  chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
475
763
  chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
476
- '',
764
+ "",
477
765
  ]);
478
766
 
479
767
  return boxen(t.toString(), {
480
- title: chalk.bold('Per Week'),
481
- borderStyle: 'round',
482
- borderColor: 'magenta',
768
+ title: chalk.bold("Per Week"),
769
+ borderStyle: "round",
770
+ borderColor: "magenta",
483
771
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
484
772
  });
485
773
  }
@@ -490,12 +778,17 @@ export function renderPerWeek(records) {
490
778
 
491
779
  export function renderTopSessions(records, n = 5) {
492
780
  const top = topSessions(records, n);
493
- if (top.length === 0) return '';
781
+ if (top.length === 0) return "";
494
782
 
495
783
  if (NARROW) {
496
784
  const t = new Table({
497
- head: [chalk.bold('#'), chalk.bold('Total'), chalk.bold('In/Out'),
498
- 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
+ ],
499
792
  style: { head: [], border: [] },
500
793
  });
501
794
  top.forEach((r, i) => {
@@ -503,22 +796,31 @@ export function renderTopSessions(records, n = 5) {
503
796
  chalk.bold(String(i + 1)),
504
797
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
505
798
  `${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
506
- r.cost ? '$' + r.cost.toFixed(4) : '—',
507
- truncEnd(r.title, 30) || '—',
799
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
800
+ truncEnd(r.title, 30) || "—",
508
801
  ]);
509
802
  });
510
- return boxen(t.toString() + '\n' + chalk.dim('(full project + model in wide mode)'),
511
- { title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
512
- borderStyle: 'round', borderColor: 'yellow',
513
- 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
+ );
514
812
  }
515
813
 
516
814
  const t = new Table({
517
815
  head: [
518
- chalk.bold('#'), chalk.bold('Total'),
519
- chalk.bold('Input'), chalk.bold('Output'),
520
- chalk.bold('Cost'), chalk.bold('Model'),
521
- 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"),
522
824
  ],
523
825
  style: { head: [], border: [] },
524
826
  });
@@ -528,16 +830,16 @@ export function renderTopSessions(records, n = 5) {
528
830
  chalk.bold.yellow(fmtCompact(r.tokensTotal)),
529
831
  fmtCompact(r.tokensInput),
530
832
  fmtCompact(r.tokensOutput),
531
- r.cost ? '$' + r.cost.toFixed(4) : '—',
833
+ r.cost ? "$" + r.cost.toFixed(4) : "—",
532
834
  shortModel(r.model),
533
835
  truncEnd(r.project, 30),
534
- truncEnd(r.title, 40) || '—',
836
+ truncEnd(r.title, 40) || "—",
535
837
  ]);
536
838
  });
537
839
  return boxen(t.toString(), {
538
840
  title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
539
- borderStyle: 'round',
540
- borderColor: 'yellow',
841
+ borderStyle: "round",
842
+ borderColor: "yellow",
541
843
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
542
844
  });
543
845
  }
@@ -548,53 +850,78 @@ export function renderTopSessions(records, n = 5) {
548
850
 
549
851
  export function renderNotes(detections, errors) {
550
852
  const lines = [];
551
- lines.push(chalk.bold.underline('Token definitions'));
552
- lines.push(` • ${chalk.cyan('Input')} — prompt tokens (what was sent to the model)`);
553
- lines.push(` • ${chalk.green('Output')} — completion tokens (what the model generated)`);
554
- lines.push(` • ${chalk.cyan('Cache Read')} — prompt tokens served from provider cache (cheap)`);
555
- lines.push(` • ${chalk.cyan('Cache Write')} — prompt tokens cached for future use (OpenCode only)`);
556
- lines.push(` • ${chalk.yellow('Reasoning')} — extended thinking / chain-of-thought tokens`);
557
-
558
- lines.push('');
559
- lines.push(chalk.bold.underline('Detected tools with token data'));
560
- 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
+ );
561
875
  if (withTokens.length === 0) {
562
- lines.push(' ' + chalk.dim('(none — only presence info available)'));
876
+ lines.push(" " + chalk.dim("(none — only presence info available)"));
563
877
  } else {
564
878
  for (const d of withTokens) {
565
- 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
+ );
566
882
  }
567
883
  }
568
884
 
569
- lines.push('');
570
- lines.push(chalk.bold.underline('Detected tools without token data (presence only)'));
571
- 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
+ );
572
892
  if (noTokens.length === 0) {
573
- lines.push(' ' + chalk.dim('(none)'));
893
+ lines.push(" " + chalk.dim("(none)"));
574
894
  } else {
575
895
  for (const d of noTokens) {
576
- 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
+ );
577
899
  }
578
900
  }
579
901
 
580
- lines.push('');
581
- lines.push(chalk.bold.underline('Env overrides'));
582
- lines.push(' $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME $MIMOCODE_HOME');
583
- lines.push(' $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME');
584
- 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
+ );
585
911
 
586
912
  if (errors && errors.length) {
587
- lines.push('');
913
+ lines.push("");
588
914
  lines.push(chalk.bold.underline(chalk.red(`Errors (${errors.length})`)));
589
915
  for (const e of errors.slice(0, 5)) {
590
- lines.push(' ' + chalk.red('! ') + e);
916
+ lines.push(" " + chalk.red("! ") + e);
591
917
  }
592
- 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`));
593
920
  }
594
921
 
595
- return boxen(lines.join('\n'), {
596
- borderStyle: 'round',
597
- borderColor: 'gray',
922
+ return boxen(lines.join("\n"), {
923
+ borderStyle: "round",
924
+ borderColor: "gray",
598
925
  padding: { top: 0, bottom: 0, left: 1, right: 1 },
599
926
  });
600
927
  }