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