ai-usage-analyzer 0.1.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 ADDED
@@ -0,0 +1,544 @@
1
+ // TUI renderer using chalk + cli-table3 + boxen + gradient-string.
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 {
9
+ perProject, perMonth, perWeek, perTool, overall, topSessions, tokenBreakdown,
10
+ MONTH_NAMES,
11
+ } from './aggregate.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Terminal width detection
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function detectWidth() {
18
+ // 1. process.stdout.columns (when piped to terminal)
19
+ if (process.stdout.columns && Number.isFinite(process.stdout.columns)) {
20
+ return process.stdout.columns;
21
+ }
22
+ // 2. COLUMNS env var (some terminals set this)
23
+ if (process.env.COLUMNS) {
24
+ const n = parseInt(process.env.COLUMNS, 10);
25
+ if (Number.isFinite(n) && n > 0) return n;
26
+ }
27
+ // 3. tput cols (POSIX)
28
+ try {
29
+ const { execSync } = require('node:child_process');
30
+ const out = execSync('tput cols 2>/dev/null', { encoding: 'utf8' });
31
+ const n = parseInt(out.trim(), 10);
32
+ if (Number.isFinite(n) && n > 0) return n;
33
+ } catch {}
34
+ // 4. default
35
+ return 100;
36
+ }
37
+
38
+ export const TERM_WIDTH = detectWidth();
39
+ export const NARROW = TERM_WIDTH < 110;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Number formatting
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export function fmtInt(n) {
46
+ return Number(n).toLocaleString('en-US');
47
+ }
48
+
49
+ export function fmtCompact(n) {
50
+ const a = Math.abs(n);
51
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
52
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
53
+ if (a >= 1e3) return (n / 1e3).toFixed(1) + 'K';
54
+ return String(n);
55
+ }
56
+
57
+ export function fmtCost(n) {
58
+ if (!n) return '—';
59
+ return '$' + Number(n).toFixed(2);
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Color helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const TOOL_COLORS = {
67
+ opencode: 'cyan',
68
+ codex: 'magenta',
69
+ mimocode: 'yellow',
70
+ claude: 'blue',
71
+ copilot: 'green',
72
+ antigravity: 'red',
73
+ gemini: 'gray',
74
+ };
75
+
76
+ export function toolColor(t) { return TOOL_COLORS[t] || 'white'; }
77
+ export function colorize(t, c) { return chalk.hex(toHex(c))(t); }
78
+
79
+ function toHex(name) {
80
+ const m = {
81
+ red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
82
+ blue: '#8be9fd', magenta: '#ff79c6', cyan: '#8be9fd',
83
+ white: '#f8f8f2', gray: '#6272a4',
84
+ };
85
+ return m[name] || '#ffffff';
86
+ }
87
+
88
+ function bar(value, max, width, color) {
89
+ if (max <= 0) return ' '.repeat(width);
90
+ const pct = Math.max(0, Math.min(1, value / max));
91
+ const filled = Math.round(pct * width);
92
+ const empty = width - filled;
93
+ return chalk.hex(toHex(color))('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
94
+ }
95
+
96
+ function shortModel(m) {
97
+ if (!m) return '—';
98
+ if (typeof m === 'string' && m.trim().startsWith('{')) {
99
+ try {
100
+ const d = JSON.parse(m);
101
+ const id = d.id || d.model || m;
102
+ const prov = d.providerId || d.provider;
103
+ return id + (prov ? ` (${prov})` : '');
104
+ } catch { return m.slice(0, 30); }
105
+ }
106
+ return m;
107
+ }
108
+
109
+ function truncMiddle(s, w) {
110
+ if (!s) return '';
111
+ if (s.length <= w) return s;
112
+ if (w <= 1) return s.slice(0, w);
113
+ const head = Math.ceil((w - 1) / 2);
114
+ const tail = Math.floor((w - 1) / 2);
115
+ return s.slice(0, head) + '…' + s.slice(s.length - tail);
116
+ }
117
+
118
+ function truncEnd(s, w) {
119
+ if (!s) return '';
120
+ if (s.length <= w) return s;
121
+ return s.slice(0, w - 1) + '…';
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Header
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export function renderHeader({ totalSessions, totalTokens, totalCost, dateRange }) {
129
+ const title = gradient.pastel.multiline('◆ AI TOKEN ANALYZER ◆');
130
+ const sub = chalk.dim(`${totalSessions} sessions`) + ' • ' +
131
+ chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
132
+ (totalCost ? ' • ' + chalk.bold.yellow(`$${totalCost.toFixed(2)} USD`) : '');
133
+ const range = (dateRange[0] && dateRange[1])
134
+ ? chalk.dim.italic(`\n range: ${dateRange[0]} → ${dateRange[1]}`)
135
+ : '';
136
+ return boxen(`${title}\n\n${sub}${range}`, {
137
+ borderStyle: 'round',
138
+ borderColor: 'magenta',
139
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
140
+ align: 'center',
141
+ });
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Tool detection panel
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export function renderDetections(detections) {
149
+ // For narrow terminals, simplify to Tool + Status + Count + Tokens (drop Path)
150
+ if (NARROW) {
151
+ const t = new Table({
152
+ head: [chalk.bold('Tool'), chalk.bold('Status'), chalk.bold('Count'), chalk.bold('Tokens')],
153
+ style: { head: [], border: [] },
154
+ });
155
+ for (const d of detections) {
156
+ const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
157
+ const count = d.count ? fmtInt(d.count) : chalk.dim('—');
158
+ const tok = d.hasTokens ? (d.count ? chalk.cyan('yes') : chalk.dim('—')) : chalk.dim('n/a');
159
+ t.push([colorize(d.name, toolColor(d.key)), status, count, tok]);
160
+ }
161
+ return boxen(t.toString() + '\n' + chalk.dim('(paths hidden in narrow mode — use $AI_USAGE_PATHS_JSON to inspect)'),
162
+ { title: chalk.bold('AI Tools Detected'), borderStyle: 'round', borderColor: 'cyan',
163
+ padding: { top: 0, bottom: 0, left: 1, right: 1 } });
164
+ }
165
+
166
+ const t = new Table({
167
+ head: [chalk.bold('Tool'), chalk.bold('Status'), chalk.bold('Path'), chalk.bold('Count'), chalk.bold('Tokens')],
168
+ style: { head: [], border: [] },
169
+ });
170
+
171
+ for (const d of detections) {
172
+ const status = d.status === 'present' ? chalk.green('● present') : chalk.red('○ absent');
173
+ const path = d.path ? truncEnd(d.path.replace(process.env.HOME || '', '~'), 50) : chalk.dim('—');
174
+ const count = d.count ? fmtInt(d.count) : chalk.dim('—');
175
+ const tok = d.hasTokens
176
+ ? (d.count ? chalk.cyan('yes') : chalk.dim('—'))
177
+ : chalk.dim('n/a');
178
+ t.push([colorize(d.name, toolColor(d.key)), status, path, count, tok]);
179
+ }
180
+ return boxen(t.toString(), {
181
+ title: chalk.bold('AI Tools Detected'),
182
+ borderStyle: 'round',
183
+ borderColor: 'cyan',
184
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
185
+ });
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Overview (totals + token breakdown)
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export function renderOverview(records, detections) {
193
+ const t = overall(records);
194
+ const breakdown = tokenBreakdown(t);
195
+
196
+ // Token breakdown sub-table
197
+ const bt = new Table({
198
+ style: { border: ['gray'] },
199
+ colWidths: [14, 12, 12],
200
+ });
201
+ bt.push(
202
+ [chalk.bold('Type'), chalk.bold('Tokens'), chalk.bold('Share')],
203
+ [chalk.cyan('Input'), fmtCompact(breakdown.input), pct(breakdown.ratios.input)],
204
+ [chalk.green('Output'), fmtCompact(breakdown.output), pct(breakdown.ratios.output)],
205
+ [chalk.cyan('Cache Read'), fmtCompact(breakdown.cacheRead), pct(breakdown.ratios.cacheRead)],
206
+ [chalk.cyan('Cache Write'), fmtCompact(breakdown.cacheWrite), pct(breakdown.ratios.cacheWrite)],
207
+ [chalk.yellow('Reasoning'), fmtCompact(breakdown.reasoning), pct(breakdown.ratios.reasoning)],
208
+ [chalk.bold('Total'), chalk.bold(fmtCompact(breakdown.total)), '100.0%'],
209
+ );
210
+
211
+ // Per-tool mini summary
212
+ const pt = new Table({
213
+ head: [chalk.bold('Tool'), chalk.bold('n'), chalk.bold('Total'), chalk.bold('Avg/sess'), chalk.bold('Cost')],
214
+ style: { head: [], border: ['gray'] },
215
+ colWidths: [14, 5, 10, 11, 9],
216
+ });
217
+ const byTool = perTool(records);
218
+ for (const p of byTool) {
219
+ pt.push([
220
+ colorize(p.tool, toolColor(p.tool)),
221
+ fmtInt(p.n),
222
+ fmtCompact(p.tokensTotal),
223
+ fmtCompact(p.avg),
224
+ fmtCost(p.cost),
225
+ ]);
226
+ }
227
+ pt.push([
228
+ chalk.bold('TOTAL'),
229
+ chalk.bold(fmtInt(records.length)),
230
+ chalk.bold(fmtCompact(t.tokensTotal)),
231
+ chalk.bold(fmtCompact(t.avg)),
232
+ chalk.bold(fmtCost(t.cost)),
233
+ ]);
234
+
235
+ const body = pt.toString() + '\n\n' + chalk.bold.underline('Token Breakdown') + '\n' + bt.toString();
236
+ return boxen(body, {
237
+ title: chalk.bold('Overview'),
238
+ borderStyle: 'round',
239
+ borderColor: 'blue',
240
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
241
+ });
242
+ }
243
+
244
+ function pct(x) { return (x * 100).toFixed(1) + '%'; }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Per Project
248
+ // ---------------------------------------------------------------------------
249
+
250
+ export function renderPerProject(records) {
251
+ const items = perProject(records);
252
+ if (items.length === 0) return '';
253
+ const max = items[0].tokensTotal;
254
+ const barW = NARROW ? 10 : 18;
255
+
256
+ // Narrow mode: drop In/Out/Cache columns, just show n + Total + Dist
257
+ const head = NARROW
258
+ ? [chalk.bold('Tool'), chalk.bold('Project'), chalk.bold('n'),
259
+ chalk.bold('Total'), chalk.bold('Dist')]
260
+ : [chalk.bold('Tool'), chalk.bold('Project'), chalk.bold('n'),
261
+ chalk.bold('In'), chalk.bold('Out'),
262
+ chalk.bold('Cache'), chalk.bold('Total'), chalk.bold('Dist')];
263
+ const t = new Table({ head, style: { head: [], border: [] } });
264
+
265
+ for (const p of items) {
266
+ const cache = p.tokensCacheRead + p.tokensCacheWrite;
267
+ const row = [
268
+ colorize(p.tool, toolColor(p.tool)),
269
+ truncEnd(p.project, NARROW ? 26 : 38),
270
+ fmtInt(p.n),
271
+ ];
272
+ if (!NARROW) {
273
+ row.push(
274
+ fmtCompact(p.tokensInput),
275
+ fmtCompact(p.tokensOutput),
276
+ fmtCompact(cache),
277
+ );
278
+ }
279
+ row.push(
280
+ fmtCompact(p.tokensTotal),
281
+ bar(p.tokensTotal, max, barW, toolColor(p.tool)),
282
+ );
283
+ t.push(row);
284
+ }
285
+ return boxen(t.toString(), {
286
+ title: chalk.bold('Per Project'),
287
+ borderStyle: 'round',
288
+ borderColor: 'cyan',
289
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
290
+ });
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Per Month
295
+ // ---------------------------------------------------------------------------
296
+
297
+ export function renderPerMonth(records) {
298
+ const items = perMonth(records);
299
+ if (items.length === 0) return '';
300
+ const max = Math.max(...items.map(p => p.tokensTotal));
301
+ const barW = NARROW ? 10 : 16;
302
+ const t1 = perTool(records);
303
+
304
+ // Narrow mode: drop the byTool columns (OC/CX/MM), keep core metrics
305
+ const head = NARROW
306
+ ? [chalk.bold('Month'), chalk.bold('n'),
307
+ chalk.bold('Input'), chalk.bold('Output'),
308
+ chalk.bold('Total'), chalk.bold('Dist')]
309
+ : [chalk.bold('Month'), chalk.bold('n'),
310
+ chalk.bold('Input'), chalk.bold('Output'),
311
+ chalk.bold('Total'), chalk.bold('OC'),
312
+ chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
313
+ const t = new Table({ head, style: { head: [], border: [] } });
314
+
315
+ for (const p of items) {
316
+ const yyyy = p.month.slice(0, 4);
317
+ const mm = p.month.slice(5, 7);
318
+ const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
319
+ const row = [
320
+ chalk.bold(label),
321
+ fmtInt(p.n),
322
+ fmtCompact(p.tokensInput),
323
+ fmtCompact(p.tokensOutput),
324
+ fmtCompact(p.tokensTotal),
325
+ ];
326
+ if (!NARROW) {
327
+ row.push(
328
+ p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
329
+ p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
330
+ p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
331
+ );
332
+ }
333
+ row.push(bar(p.tokensTotal, max, barW, 'green'));
334
+ t.push(row);
335
+ }
336
+
337
+ // TOTAL row
338
+ const tot = overall(records);
339
+ const totalRow = [
340
+ chalk.bgGray.white.bold(' TOTAL '),
341
+ chalk.bgGray.white.bold(fmtInt(records.length)),
342
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
343
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
344
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
345
+ ];
346
+ if (!NARROW) {
347
+ totalRow.push(
348
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
349
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
350
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
351
+ );
352
+ }
353
+ totalRow.push('');
354
+ t.push(totalRow);
355
+
356
+ return boxen(t.toString(), {
357
+ title: chalk.bold('Per Bulan (Monthly)'),
358
+ borderStyle: 'round',
359
+ borderColor: 'green',
360
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
361
+ });
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Per Week
366
+ // ---------------------------------------------------------------------------
367
+
368
+ export function renderPerWeek(records) {
369
+ const items = perWeek(records);
370
+ if (items.length === 0) return '';
371
+ const max = Math.max(...items.map(p => p.tokensTotal));
372
+ const barW = NARROW ? 10 : 14;
373
+ const t1 = perTool(records);
374
+
375
+ const head = NARROW
376
+ ? [chalk.bold('ISO Week'), chalk.bold('n'),
377
+ chalk.bold('Input'), chalk.bold('Output'),
378
+ chalk.bold('Total'), chalk.bold('Dist')]
379
+ : [chalk.bold('ISO Week'), chalk.bold('n'),
380
+ chalk.bold('Input'), chalk.bold('Output'),
381
+ chalk.bold('Total'), chalk.bold('OC'),
382
+ chalk.bold('CX'), chalk.bold('MM'), chalk.bold('Dist')];
383
+ const t = new Table({ head, style: { head: [], border: [] } });
384
+
385
+ for (const p of items) {
386
+ const dominantTool = Object.entries(p.byTool).sort((a, b) => b[1] - a[1])[0]?.[0] || 'opencode';
387
+ const row = [
388
+ chalk.bold(p.week),
389
+ fmtInt(p.n),
390
+ fmtCompact(p.tokensInput),
391
+ fmtCompact(p.tokensOutput),
392
+ fmtCompact(p.tokensTotal),
393
+ ];
394
+ if (!NARROW) {
395
+ row.push(
396
+ p.byTool.opencode ? fmtCompact(p.byTool.opencode) : chalk.dim('—'),
397
+ p.byTool.codex ? fmtCompact(p.byTool.codex) : chalk.dim('—'),
398
+ p.byTool.mimocode ? fmtCompact(p.byTool.mimocode) : chalk.dim('—'),
399
+ );
400
+ }
401
+ row.push(bar(p.tokensTotal, max, barW, toolColor(dominantTool)));
402
+ t.push(row);
403
+ }
404
+ // TOTAL row
405
+ const tot = overall(records);
406
+ const totalRow = [
407
+ chalk.bgGray.white.bold(' TOTAL '),
408
+ chalk.bgGray.white.bold(fmtInt(records.length)),
409
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensInput)),
410
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensOutput)),
411
+ chalk.bgGray.white.bold(fmtCompact(tot.tokensTotal)),
412
+ ];
413
+ if (!NARROW) {
414
+ totalRow.push(
415
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'opencode')?.tokensTotal || 0)),
416
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'codex')?.tokensTotal || 0)),
417
+ chalk.bgGray.white.bold(fmtCompact(t1.find(p => p.tool === 'mimocode')?.tokensTotal || 0)),
418
+ );
419
+ }
420
+ totalRow.push('');
421
+ t.push(totalRow);
422
+
423
+ return boxen(t.toString(), {
424
+ title: chalk.bold('Per Minggu (Weekly)'),
425
+ borderStyle: 'round',
426
+ borderColor: 'magenta',
427
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
428
+ });
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Top N heaviest sessions
433
+ // ---------------------------------------------------------------------------
434
+
435
+ export function renderTopSessions(records, n = 5) {
436
+ const top = topSessions(records, n);
437
+ if (top.length === 0) return '';
438
+
439
+ if (NARROW) {
440
+ const t = new Table({
441
+ head: [chalk.bold('#'), chalk.bold('Total'), chalk.bold('In/Out'),
442
+ chalk.bold('Cost'), chalk.bold('Title')],
443
+ style: { head: [], border: [] },
444
+ });
445
+ top.forEach((r, i) => {
446
+ t.push([
447
+ chalk.bold(String(i + 1)),
448
+ chalk.bold.yellow(fmtCompact(r.tokensTotal)),
449
+ `${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
450
+ r.cost ? '$' + r.cost.toFixed(4) : '—',
451
+ truncEnd(r.title, 30) || '—',
452
+ ]);
453
+ });
454
+ return boxen(t.toString() + '\n' + chalk.dim('(full project + model in wide mode)'),
455
+ { title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
456
+ borderStyle: 'round', borderColor: 'yellow',
457
+ padding: { top: 0, bottom: 0, left: 1, right: 1 } });
458
+ }
459
+
460
+ const t = new Table({
461
+ head: [
462
+ chalk.bold('#'), chalk.bold('Total'),
463
+ chalk.bold('Input'), chalk.bold('Output'),
464
+ chalk.bold('Cost'), chalk.bold('Model'),
465
+ chalk.bold('Project'), chalk.bold('Title'),
466
+ ],
467
+ style: { head: [], border: [] },
468
+ });
469
+ top.forEach((r, i) => {
470
+ t.push([
471
+ chalk.bold(String(i + 1)),
472
+ chalk.bold.yellow(fmtCompact(r.tokensTotal)),
473
+ fmtCompact(r.tokensInput),
474
+ fmtCompact(r.tokensOutput),
475
+ r.cost ? '$' + r.cost.toFixed(4) : '—',
476
+ shortModel(r.model),
477
+ truncEnd(r.project, 30),
478
+ truncEnd(r.title, 40) || '—',
479
+ ]);
480
+ });
481
+ return boxen(t.toString(), {
482
+ title: chalk.bold.yellow(`Top ${n} Heaviest Sessions`),
483
+ borderStyle: 'round',
484
+ borderColor: 'yellow',
485
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
486
+ });
487
+ }
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Notes
491
+ // ---------------------------------------------------------------------------
492
+
493
+ export function renderNotes(detections, errors) {
494
+ const lines = [];
495
+ lines.push(chalk.bold.underline('Token definitions'));
496
+ lines.push(` • ${chalk.cyan('Input')} — prompt tokens (what was sent to the model)`);
497
+ lines.push(` • ${chalk.green('Output')} — completion tokens (what the model generated)`);
498
+ lines.push(` • ${chalk.cyan('Cache Read')} — prompt tokens served from provider cache (cheap)`);
499
+ lines.push(` • ${chalk.cyan('Cache Write')} — prompt tokens cached for future use (OpenCode only)`);
500
+ lines.push(` • ${chalk.yellow('Reasoning')} — extended thinking / chain-of-thought tokens`);
501
+
502
+ lines.push('');
503
+ lines.push(chalk.bold.underline('Detected tools with token data'));
504
+ const withTokens = detections.filter(d => d.hasTokens && d.status === 'present');
505
+ if (withTokens.length === 0) {
506
+ lines.push(' ' + chalk.dim('(none — only presence info available)'));
507
+ } else {
508
+ for (const d of withTokens) {
509
+ lines.push(` ${chalk.green('●')} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`);
510
+ }
511
+ }
512
+
513
+ lines.push('');
514
+ lines.push(chalk.bold.underline('Detected tools without token data (presence only)'));
515
+ const noTokens = detections.filter(d => !d.hasTokens && d.status === 'present');
516
+ if (noTokens.length === 0) {
517
+ lines.push(' ' + chalk.dim('(none)'));
518
+ } else {
519
+ for (const d of noTokens) {
520
+ lines.push(` ${chalk.green('●')} ${colorize(d.name, toolColor(d.key))} ${chalk.dim(d.description)}`);
521
+ }
522
+ }
523
+
524
+ lines.push('');
525
+ lines.push(chalk.bold.underline('Env overrides'));
526
+ lines.push(' $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME $MIMOCODE_HOME');
527
+ lines.push(' $COPILOT_HOME $ANTIGRAVITY_HOME $GEMINI_HOME');
528
+ lines.push(' $AI_USAGE_PATHS_JSON = ' + chalk.italic('\'{"codex":"/custom/path"}\''));
529
+
530
+ if (errors && errors.length) {
531
+ lines.push('');
532
+ lines.push(chalk.bold.underline(chalk.red(`Errors (${errors.length})`)));
533
+ for (const e of errors.slice(0, 5)) {
534
+ lines.push(' ' + chalk.red('! ') + e);
535
+ }
536
+ if (errors.length > 5) lines.push(' ' + chalk.dim(`... and ${errors.length - 5} more`));
537
+ }
538
+
539
+ return boxen(lines.join('\n'), {
540
+ borderStyle: 'round',
541
+ borderColor: 'gray',
542
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
543
+ });
544
+ }