tledger 0.1.3 → 0.2.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.
@@ -1,15 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { existsSync, realpathSync } from "node:fs";
4
5
  import {
6
+ mkdir,
5
7
  readFile,
6
8
  stat,
7
9
  } from "node:fs/promises";
8
10
  import { homedir } from "node:os";
9
- import { basename, resolve } from "node:path";
11
+ import { basename, dirname, resolve } from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
 
12
- import { renderTerminal } from "./token-ledger-terminal.mjs";
14
+ import {
15
+ MODEL_COLORS as TERMINAL_MODEL_COLORS,
16
+ renderTerminal,
17
+ } from "./token-ledger-terminal.mjs";
18
+ import { buildUsageTrend, multiDayBounds } from "./token-ledger-trend.mjs";
19
+ import {
20
+ renderTrendImage,
21
+ writeTrendPng,
22
+ } from "./token-ledger-trend-image.mjs";
23
+ import { renderTrendCombo } from "./token-ledger-trend-terminal.mjs";
13
24
  import { startInteractive } from "./token-ledger-tui.mjs";
14
25
 
15
26
  export const DEFAULT_SNAPSHOT = resolve(
@@ -19,17 +30,31 @@ export const DEFAULT_SNAPSHOT = resolve(
19
30
  );
20
31
  const DEFAULT_TOP = 10;
21
32
  const DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ export const SNAPSHOT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
34
+ const ANSI_RESET = "\u001b[0m";
35
+ const MODEL_COLORS = {
36
+ sol: TERMINAL_MODEL_COLORS.sol,
37
+ luna: TERMINAL_MODEL_COLORS.luna,
38
+ terra: TERMINAL_MODEL_COLORS.terra,
39
+ "gpt-5.5": TERMINAL_MODEL_COLORS.gpt,
40
+ "gpt-5.4": TERMINAL_MODEL_COLORS.gpt,
41
+ other: TERMINAL_MODEL_COLORS.other,
42
+ };
22
43
 
23
44
  function usage() {
24
45
  return `Token Ledger terminal usage
25
46
 
26
47
  Usage:
27
- tledger
48
+ tledger day <YYYY-MM-DD>
28
49
  tledger week [end-day]
29
- tledger day [day]
50
+ tledger trend [7d|14d|30d]
51
+ tledger report [7d|14d|30d]
52
+ npm run usage:day -- <YYYY-MM-DD>
53
+ npm run usage:week -- [end-day]
30
54
 
31
55
  Options:
32
56
  --date <day> Date as YYYY-MM-DD, today, or yesterday
57
+ --period <window> Trend window: 7d, 14d, or 30d
33
58
  --input <file> Snapshot to read (default: ~/.token-ledger/token-ledger-snapshot.json)
34
59
  --refresh Rebuild the default snapshot from CODEX_HOME or ~/.codex
35
60
  --no-refresh Use the cached snapshot without checking local JSONL files
@@ -42,10 +67,18 @@ Options:
42
67
  --plain Disable terminal colors
43
68
  --ascii Use ASCII bars instead of Unicode blocks
44
69
  --static Print once instead of opening the interactive dashboard
70
+ --drain Trend columns show observed limit drain percent instead of token volume
71
+ --image Write trend view as a PNG image
72
+ --image-output <file> PNG output path for trend view
73
+ --image-width <px> PNG image width from 900 to 2400 pixels
74
+ --youplot Use the legacy single-series YouPlot renderer
45
75
  --help Show this help
46
76
 
47
- The default view is the seven-day window ending today. Token Ledger never
48
- uploads data or renders message bodies, tool payloads, or credentials.`;
77
+ The report command writes the dashboard PNG (same as trend --image) to
78
+ token-ledger-report-<period>.png; use --image-output to choose the path.
79
+
80
+ The command reads a privacy-reduced Token Ledger snapshot. It never uploads
81
+ the snapshot or prints message bodies, tool payloads, credentials, or paths.`;
49
82
  }
50
83
 
51
84
  function readOption(argv, index, name) {
@@ -57,13 +90,16 @@ function readOption(argv, index, name) {
57
90
  }
58
91
 
59
92
  export function parseArgs(argv) {
60
- const commandExplicit = argv[0] === "day" || argv[0] === "week";
61
- if (argv[0] && !argv[0].startsWith("-") && !commandExplicit) {
62
- throw new Error(`Unknown command: ${argv[0]}. Use day or week.`);
63
- }
64
- const command = commandExplicit ? argv[0] : "week";
93
+ const command = argv[0] === "week"
94
+ ? "week"
95
+ : argv[0] === "trend" || argv[0] === "report"
96
+ ? "trend"
97
+ : "day";
65
98
  const options = {
66
99
  range: command,
100
+ view: command === "trend" ? "trend" : "projects",
101
+ report: argv[0] === "report",
102
+ trendDays: 7,
67
103
  date: null,
68
104
  input: DEFAULT_SNAPSHOT,
69
105
  inputExplicit: false,
@@ -78,10 +114,16 @@ export function parseArgs(argv) {
78
114
  plain: false,
79
115
  ascii: false,
80
116
  static: false,
117
+ image: false,
118
+ imageOutput: null,
119
+ imageWidth: null,
120
+ drain: false,
121
+ legacyPlot: false,
81
122
  help: false,
82
123
  };
83
124
 
84
- let index = commandExplicit ? 1 : 0;
125
+ let trendPeriodSeen = false;
126
+ let index = ["day", "week", "trend", "report"].includes(argv[0]) ? 1 : 0;
85
127
  for (; index < argv.length; index += 1) {
86
128
  const argument = argv[index];
87
129
  if (argument === "--help" || argument === "-h") {
@@ -89,6 +131,20 @@ export function parseArgs(argv) {
89
131
  } else if (argument === "--date") {
90
132
  options.date = readOption(argv, index, "--date");
91
133
  index += 1;
134
+ } else if (argument === "--period") {
135
+ if (options.view !== "trend") {
136
+ throw new Error("--period is only available for the trend view.");
137
+ }
138
+ if (trendPeriodSeen) {
139
+ throw new Error("Trend period can only be specified once.");
140
+ }
141
+ const value = readOption(argv, index, "--period");
142
+ if (!["7d", "14d", "30d"].includes(value)) {
143
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
144
+ }
145
+ options.trendDays = Number.parseInt(value, 10);
146
+ trendPeriodSeen = true;
147
+ index += 1;
92
148
  } else if (argument === "--input") {
93
149
  options.input = resolve(readOption(argv, index, "--input"));
94
150
  options.inputExplicit = true;
@@ -127,22 +183,72 @@ export function parseArgs(argv) {
127
183
  options.ascii = true;
128
184
  } else if (argument === "--static") {
129
185
  options.static = true;
130
- } else if (commandExplicit && !argument.startsWith("-") && !options.date) {
186
+ } else if (argument === "--drain") {
187
+ if (options.view !== "trend") {
188
+ throw new Error("--drain is only available for the trend view.");
189
+ }
190
+ options.drain = true;
191
+ } else if (argument === "--image") {
192
+ if (options.view !== "trend") {
193
+ throw new Error("--image is only available for the trend view.");
194
+ }
195
+ options.image = true;
196
+ } else if (argument === "--image-output") {
197
+ if (options.view !== "trend") {
198
+ throw new Error("--image-output is only available for the trend view.");
199
+ }
200
+ const value = readOption(argv, index, "--image-output");
201
+ if (!value.toLowerCase().endsWith(".png")) {
202
+ throw new Error("--image-output must end in .png.");
203
+ }
204
+ options.image = true;
205
+ options.imageOutput = resolve(value);
206
+ index += 1;
207
+ } else if (argument === "--image-width") {
208
+ if (options.view !== "trend") {
209
+ throw new Error("--image-width is only available for the trend view.");
210
+ }
211
+ const value = Number(readOption(argv, index, "--image-width"));
212
+ if (!Number.isInteger(value) || value < 900 || value > 2400) {
213
+ throw new Error("--image-width must be an integer from 900 to 2400.");
214
+ }
215
+ options.image = true;
216
+ options.imageWidth = value;
217
+ index += 1;
218
+ } else if (argument === "--youplot") {
219
+ options.legacyPlot = true;
220
+ } else if (!argument.startsWith("-") && options.view === "trend") {
221
+ if (trendPeriodSeen) {
222
+ throw new Error("Trend period can only be specified once.");
223
+ }
224
+ if (!["7d", "14d", "30d"].includes(argument)) {
225
+ throw new Error("Trend period must be 7d, 14d, or 30d.");
226
+ }
227
+ options.trendDays = Number.parseInt(argument, 10);
228
+ trendPeriodSeen = true;
229
+ } else if (!argument.startsWith("-") && !options.date) {
131
230
  options.date = argument;
132
231
  } else {
133
232
  throw new Error(`Unknown option: ${argument}`);
134
233
  }
135
234
  }
136
235
 
137
- if (!options.help && !options.date) {
236
+ if (options.report) options.image = true;
237
+ if (!options.help && !options.date && (options.range === "week" || options.view === "trend")) {
138
238
  options.date = "today";
139
239
  }
240
+ if (!options.help && !options.date) {
241
+ throw new Error("A day is required, for example: tledger day 2026-08-01");
242
+ }
140
243
  if (!options.help && options.refresh && !options.autoRefresh) {
141
244
  throw new Error("--refresh cannot be combined with --no-refresh.");
142
245
  }
143
246
  if (!options.help && options.refresh && options.inputExplicit) {
144
247
  throw new Error("--refresh cannot be combined with --input.");
145
248
  }
249
+ if (!options.help && options.view === "trend" && options.legacyPlot) {
250
+ throw new Error("--youplot is only available for the project view.");
251
+ }
146
252
  return options;
147
253
  }
148
254
 
@@ -336,6 +442,8 @@ export function aggregateProjects(snapshot, events, options = {}) {
336
442
  toolCalls: 0,
337
443
  events: 0,
338
444
  threadIds: new Set(),
445
+ rateCardCredits: 0,
446
+ knownCreditTokens: 0,
339
447
  models: new Map(),
340
448
  };
341
449
  row.totalTokens += Number(event.totalTokens) || 0;
@@ -344,14 +452,23 @@ export function aggregateProjects(snapshot, events, options = {}) {
344
452
  row.toolCalls += Number(event.toolCalls) || 0;
345
453
  row.events += 1;
346
454
  if (event.threadId) row.threadIds.add(event.threadId);
455
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
456
+ row.rateCardCredits += Number(event.rateCardCredits);
457
+ row.knownCreditTokens += Number(event.totalTokens) || 0;
458
+ }
459
+
347
460
  const model = modelLabel(event.model);
348
461
  const modelRow = row.models.get(model) ?? {
349
462
  model,
350
463
  totalTokens: 0,
351
464
  events: 0,
465
+ rateCardCredits: 0,
352
466
  };
353
467
  modelRow.totalTokens += Number(event.totalTokens) || 0;
354
468
  modelRow.events += 1;
469
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
470
+ modelRow.rateCardCredits += Number(event.rateCardCredits);
471
+ }
355
472
  row.models.set(model, modelRow);
356
473
  grouped.set(project, row);
357
474
  }
@@ -372,56 +489,141 @@ export function aggregateProjects(snapshot, events, options = {}) {
372
489
  });
373
490
  }
374
491
 
375
- function sourceLabel(snapshotPath, snapshot) {
376
- const generated = snapshot.generatedAt
377
- ? new Date(snapshot.generatedAt).toLocaleString("en-US", {
378
- dateStyle: "medium",
379
- timeStyle: "short",
380
- })
381
- : "unknown time";
382
- return `${cleanLabel(basename(snapshotPath), "snapshot")} · captured ${generated}`;
492
+ function totalSummary(events) {
493
+ return events.reduce(
494
+ (summary, event) => {
495
+ summary.totalTokens += Number(event.totalTokens) || 0;
496
+ summary.outputTokens += Number(event.outputTokens) || 0;
497
+ summary.toolCalls += Number(event.toolCalls) || 0;
498
+ if (event.threadId) summary.threadIds.add(event.threadId);
499
+ if (event.rateCardCredits !== null && Number.isFinite(Number(event.rateCardCredits))) {
500
+ summary.rateCardCredits += Number(event.rateCardCredits);
501
+ summary.knownCreditTokens += Number(event.totalTokens) || 0;
502
+ }
503
+ return summary;
504
+ },
505
+ {
506
+ totalTokens: 0,
507
+ outputTokens: 0,
508
+ toolCalls: 0,
509
+ rateCardCredits: 0,
510
+ knownCreditTokens: 0,
511
+ threadIds: new Set(),
512
+ },
513
+ );
383
514
  }
384
515
 
385
- function latestActivityDateString(snapshot, timeZone) {
386
- let latestTimestamp = Number.NEGATIVE_INFINITY;
387
- for (const event of snapshot.events ?? []) {
388
- const timestamp = new Date(event.timestamp).getTime();
389
- if (Number.isFinite(timestamp) && timestamp > latestTimestamp) {
390
- latestTimestamp = timestamp;
516
+ function compact(value, digits = 2) {
517
+ if (!Number.isFinite(value)) return "—";
518
+ const absolute = Math.abs(value);
519
+ const units = [
520
+ [1_000_000_000, "B"],
521
+ [1_000_000, "M"],
522
+ [1_000, "K"],
523
+ ];
524
+ for (const [divisor, suffix] of units) {
525
+ if (absolute >= divisor) {
526
+ const scaled = value / divisor;
527
+ const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
528
+ return `${scaled.toFixed(precision)}${suffix}`;
391
529
  }
392
530
  }
393
- if (!Number.isFinite(latestTimestamp)) return null;
394
- return dateStringFromParts(numericDateParts({
395
- value: new Date(latestTimestamp),
396
- timeZone,
397
- }));
531
+ return Math.round(value).toLocaleString("en-US");
398
532
  }
399
533
 
400
- function displayCalendarDate(dateString) {
401
- const [year, month, day] = dateString.split("-").map(Number);
402
- return new Intl.DateTimeFormat("en-US", {
403
- month: "long",
404
- day: "numeric",
405
- year: "numeric",
406
- timeZone: "UTC",
407
- }).format(new Date(Date.UTC(year, month - 1, day)));
534
+ function percent(value) {
535
+ return `${value.toFixed(value >= 10 ? 1 : 2)}%`;
408
536
  }
409
537
 
410
- function emptyState(options, snapshot, bounds) {
411
- const rangeDescription = options.range === "week"
412
- ? `${bounds.startDateString} through ${bounds.endDateString}`
413
- : bounds.dateString;
414
- const lines = [
415
- `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
416
- "Token Ledger reads only Codex history stored on this computer.",
538
+ function chartUnit(maximum) {
539
+ if (maximum >= 1_000_000_000) return { divisor: 1_000_000_000, suffix: "B" };
540
+ if (maximum >= 1_000_000) return { divisor: 1_000_000, suffix: "M" };
541
+ if (maximum >= 1_000) return { divisor: 1_000, suffix: "K" };
542
+ return { divisor: 1, suffix: "tokens" };
543
+ }
544
+
545
+ function chartNumber(value, divisor) {
546
+ const scaled = value / divisor;
547
+ if (scaled >= 100) return scaled.toFixed(0);
548
+ if (scaled >= 10) return scaled.toFixed(1);
549
+ return scaled.toFixed(2);
550
+ }
551
+
552
+ function colorize(value, code, enabled) {
553
+ const codes = Array.isArray(code) ? code.join(";") : code;
554
+ return enabled ? `\u001b[${codes}m${value}${ANSI_RESET}` : value;
555
+ }
556
+
557
+ function modelColor(model) {
558
+ const lower = model.toLowerCase();
559
+ if (lower.includes("sol")) return MODEL_COLORS.sol;
560
+ if (lower.includes("luna")) return MODEL_COLORS.luna;
561
+ if (lower.includes("terra")) return MODEL_COLORS.terra;
562
+ if (lower.includes("gpt-5.5") || lower.includes("gpt-5.4")) {
563
+ return MODEL_COLORS["gpt-5.5"];
564
+ }
565
+ return MODEL_COLORS.other;
566
+ }
567
+
568
+ function modelMix(row, enabled) {
569
+ return row.models
570
+ .slice(0, 4)
571
+ .map((model) => {
572
+ const share = row.totalTokens > 0 ? (model.totalTokens / row.totalTokens) * 100 : 0;
573
+ return `${colorize(model.model, modelColor(model.model), enabled)} ${percent(share)}`;
574
+ })
575
+ .join(" · ");
576
+ }
577
+
578
+ function sourceLabel(snapshotPath, snapshot) {
579
+ const generated = snapshot.generatedAt
580
+ ? new Date(snapshot.generatedAt).toLocaleString("en-US", {
581
+ dateStyle: "medium",
582
+ timeStyle: "short",
583
+ })
584
+ : "unknown time";
585
+ return `${basename(snapshotPath)} · captured ${generated}`;
586
+ }
587
+
588
+ function runYouPlot(rows, options, dateLabel, unit) {
589
+ const chartInput = [
590
+ "project\tvalue",
591
+ ...rows.map(
592
+ (row) => `${row.displayProject.replace(/[\t\r\n]+/g, " ")}\t${chartNumber(row.totalTokens, unit.divisor)}`,
593
+ ),
594
+ ].join("\n");
595
+ const terminalWidth = Number(process.stdout.columns) || 100;
596
+ const width = options.width ?? Math.max(56, Math.min(110, terminalWidth - 4));
597
+ const useColor = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
598
+ const args = [
599
+ "bar",
600
+ "-H",
601
+ "-o",
602
+ "-",
603
+ "-t",
604
+ `Top ${rows.length} projects · tokens (${unit.suffix}) · ${dateLabel}`,
605
+ "-w",
606
+ String(width),
607
+ "--symbol",
608
+ options.ascii ? "#" : "█",
417
609
  ];
418
- const latestDate = latestActivityDateString(snapshot, bounds.timeZone);
419
- if (latestDate) {
420
- lines.push(`Latest local activity: ${displayCalendarDate(latestDate)}.`);
421
- lines.push(`Try: tledger ${options.range} ${latestDate}`);
610
+ if (useColor) args.push("-C", "-c", "blue");
611
+ else args.push("-M");
612
+
613
+ const result = spawnSync("uplot", args, {
614
+ input: `${chartInput}\n`,
615
+ encoding: "utf8",
616
+ maxBuffer: 1_000_000,
617
+ });
618
+ if (result.error?.code === "ENOENT") {
619
+ throw new Error(
620
+ "YouPlot is required. Install it with `brew install youplot`, then rerun this command.",
621
+ );
622
+ }
623
+ if (result.status !== 0) {
624
+ throw new Error(result.stderr?.trim() || "YouPlot failed to render the chart.");
422
625
  }
423
- lines.push(`Source: ${sourceLabel(options.input, snapshot)}`);
424
- return lines.join("\n");
626
+ return result.stdout;
425
627
  }
426
628
 
427
629
  async function readSnapshot(snapshotPath) {
@@ -430,28 +632,22 @@ async function readSnapshot(snapshotPath) {
430
632
  parsed = JSON.parse(await readFile(snapshotPath, "utf8"));
431
633
  } catch (error) {
432
634
  if (error?.code === "ENOENT") {
433
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(snapshotPath)}`);
635
+ throw new Error(`Snapshot not found: ${snapshotPath}`);
434
636
  }
435
- throw new Error(
436
- `Could not read snapshot ${sanitizeTerminalText(snapshotPath)}: ${sanitizeTerminalText(error.message)}`,
437
- );
637
+ throw new Error(`Could not read snapshot ${snapshotPath}: ${error.message}`);
438
638
  }
439
639
  if (!parsed || !Array.isArray(parsed.events)) {
440
- throw new Error(
441
- `Snapshot is missing its events array: ${sanitizeTerminalText(snapshotPath)}`,
442
- );
640
+ throw new Error(`Snapshot is missing its events array: ${snapshotPath}`);
443
641
  }
444
642
  return parsed;
445
643
  }
446
644
 
447
645
  async function refreshSnapshot(options) {
448
646
  if (!existsSync(options.codexHome)) {
449
- throw new Error(
450
- `Codex data directory not found: ${sanitizeTerminalText(options.codexHome)}`,
451
- );
647
+ throw new Error(`Codex data directory not found: ${options.codexHome}`);
452
648
  }
453
649
  const { collectUsage, writePrivateSnapshot } = await import(
454
- "../lib/token-ledger-collector.mjs"
650
+ "../lib/token-ledger-importer.mjs"
455
651
  );
456
652
  process.stderr.write("Token Ledger: refreshing local snapshot…\n");
457
653
  const snapshot = await collectUsage(
@@ -470,18 +666,19 @@ async function refreshSnapshot(options) {
470
666
  return snapshot;
471
667
  }
472
668
 
473
- export function snapshotNeedsRefresh(
669
+ export function snapshotNeedsRefresh(snapshotMtimeMs, latestJsonlMtimeMs) {
670
+ return latestJsonlMtimeMs > snapshotMtimeMs;
671
+ }
672
+
673
+ export function snapshotCacheIsFresh(
474
674
  snapshotMtimeMs,
475
- latestSourceMtimeMs,
476
- cachedSourceFingerprint,
477
- expectedSourceFingerprint,
478
- cachedSourceFileCount,
479
- currentSourceFileCount,
675
+ nowMs = Date.now(),
480
676
  ) {
481
677
  return (
482
- cachedSourceFingerprint !== expectedSourceFingerprint ||
483
- cachedSourceFileCount !== currentSourceFileCount ||
484
- latestSourceMtimeMs > snapshotMtimeMs
678
+ Number.isFinite(snapshotMtimeMs) &&
679
+ Number.isFinite(nowMs) &&
680
+ snapshotMtimeMs <= nowMs &&
681
+ nowMs - snapshotMtimeMs < SNAPSHOT_CACHE_MAX_AGE_MS
485
682
  );
486
683
  }
487
684
 
@@ -491,7 +688,7 @@ async function loadSnapshot(options) {
491
688
  }
492
689
  if (!existsSync(options.input)) {
493
690
  if (options.inputExplicit || !options.autoRefresh) {
494
- throw new Error(`Snapshot not found: ${sanitizeTerminalText(options.input)}`);
691
+ throw new Error(`Snapshot not found: ${options.input}`);
495
692
  }
496
693
  return refreshSnapshot(options);
497
694
  }
@@ -499,49 +696,135 @@ async function loadSnapshot(options) {
499
696
  return readSnapshot(options.input);
500
697
  }
501
698
 
502
- const { sourceFingerprint, sourceState } = await import(
503
- "../lib/token-ledger-collector.mjs"
699
+ const snapshotStat = await stat(options.input);
700
+ if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
701
+ return readSnapshot(options.input);
702
+ }
703
+
704
+ const { latestSourceModifiedAt } = await import("../lib/token-ledger-importer.mjs");
705
+ const latestSourceMtimeMs = await latestSourceModifiedAt(
706
+ options.codexHome,
707
+ options.includeArchived,
504
708
  );
505
- const [snapshotStat, currentSourceState, snapshot] = await Promise.all([
506
- stat(options.input),
507
- sourceState(options.codexHome, options.includeArchived),
508
- readSnapshot(options.input),
509
- ]);
510
- if (snapshotNeedsRefresh(
511
- snapshotStat.mtimeMs,
512
- currentSourceState.latestMtimeMs,
513
- snapshot.provenance?.sourceFingerprint,
514
- sourceFingerprint(options.codexHome, options.includeArchived),
515
- snapshot.coverage?.sourceFileCount,
516
- currentSourceState.fileCount,
517
- )) {
709
+ if (snapshotNeedsRefresh(snapshotStat.mtimeMs, latestSourceMtimeMs)) {
518
710
  return refreshSnapshot(options);
519
711
  }
520
- return snapshot;
712
+ return readSnapshot(options.input);
521
713
  }
522
714
 
523
715
  function render(options, snapshot, bounds, events, rows, allRows) {
524
- return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
716
+ if (options.view === "trend") {
717
+ const trend = buildUsageTrend(snapshot, bounds);
718
+ if (options.image) {
719
+ return renderTrendImage({
720
+ snapshot,
721
+ bounds,
722
+ trend,
723
+ days: options.trendDays,
724
+ options,
725
+ });
726
+ }
727
+ return renderTrendCombo({
728
+ snapshot,
729
+ bounds,
730
+ trend,
731
+ days: options.trendDays,
732
+ options,
733
+ });
734
+ }
735
+ if (!options.legacyPlot) {
736
+ return renderTerminal({ options, snapshot, bounds, events, rows, allRows });
737
+ }
738
+ const enabled = !options.plain && !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
739
+ const summary = totalSummary(events);
740
+ const totalTokens = summary.totalTokens;
741
+ const dateLabel = options.range === "week"
742
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
743
+ : new Intl.DateTimeFormat("en-US", {
744
+ timeZone: bounds.timeZone,
745
+ weekday: "short",
746
+ month: "short",
747
+ day: "numeric",
748
+ year: "numeric",
749
+ }).format(bounds.start);
750
+ const unit = chartUnit(rows[0]?.totalTokens ?? 0);
751
+ const shares = rows.map((row) =>
752
+ totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0,
753
+ );
754
+ const chart = runYouPlot(rows, options, dateLabel, unit).trimEnd();
755
+
756
+ const header = [
757
+ `Token Ledger · ${dateLabel} · ${bounds.timeZone}`,
758
+ `${compact(totalTokens)} tokens · ${summary.threadIds.size.toLocaleString()} threads · ${events.length.toLocaleString()} calls · ${compact(summary.outputTokens)} output`,
759
+ `Source: ${sourceLabel(options.input, snapshot)}`,
760
+ "",
761
+ chart,
762
+ "",
763
+ `Model mix · colors: ${colorize("Sol", MODEL_COLORS.sol, enabled)} ${colorize("Luna", MODEL_COLORS.luna, enabled)} ${colorize("Terra", MODEL_COLORS.terra, enabled)} ${colorize("GPT", MODEL_COLORS["gpt-5.5"], enabled)} ${colorize("Other", MODEL_COLORS.other, enabled)}`,
764
+ ];
765
+
766
+ const details = rows.map((row, index) => {
767
+ const knownCreditShare =
768
+ summary.rateCardCredits > 0 && row.rateCardCredits > 0
769
+ ? ` · ${percent((row.rateCardCredits / summary.rateCardCredits) * 100)} credits`
770
+ : "";
771
+ return `${String(index + 1).padStart(2, " ")} ${row.displayProject} · ${compact(row.totalTokens)} · ${percent(shares[index])} · ${row.threads.toLocaleString()} threads${knownCreditShare}\n ${modelMix(row, enabled)}`;
772
+ });
773
+
774
+ return `${header.join("\n")}\n\n${details.join("\n")}`;
525
775
  }
526
776
 
527
777
  export async function run(options) {
528
- const bounds = options.range === "week"
529
- ? weekBounds(options.date, options.timeZone)
530
- : dayBounds(options.date, options.timeZone);
778
+ const bounds = options.view === "trend"
779
+ ? multiDayBounds(options.date, options.timeZone, options.trendDays)
780
+ : options.range === "week"
781
+ ? weekBounds(options.date, options.timeZone)
782
+ : dayBounds(options.date, options.timeZone);
531
783
  const snapshot = await loadSnapshot(options);
532
784
  const events = filterDayEvents(snapshot, bounds);
533
785
  if (events.length === 0) {
534
- return emptyState(options, snapshot, bounds);
786
+ const rangeDescription = options.range === "week"
787
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
788
+ : bounds.dateString;
789
+ return [
790
+ `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
791
+ `Source: ${sourceLabel(options.input, snapshot)}`,
792
+ ].join("\n");
535
793
  }
536
794
  const allRows = aggregateProjects(snapshot, events, options);
537
795
  const rows = allRows.slice(0, options.top);
538
- return render(options, snapshot, bounds, events, rows, allRows);
796
+ const writingImage = options.view === "trend" && options.image;
797
+ const outputPath = writingImage
798
+ ? options.imageOutput ??
799
+ resolve(
800
+ process.cwd(),
801
+ `token-ledger-${options.report ? "report" : "trend"}-${options.trendDays}d.png`,
802
+ )
803
+ : null;
804
+ const imageLabel = options.report ? "report" : "trend image";
805
+ if (writingImage) {
806
+ process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
807
+ }
808
+ const output = render(options, snapshot, bounds, events, rows, allRows);
809
+ if (writingImage) {
810
+ await mkdir(dirname(outputPath), { recursive: true });
811
+ process.stderr.write(`Token Ledger: encoding ${imageLabel} PNG…\n`);
812
+ await writeTrendPng(output, outputPath);
813
+ process.stderr.write(`Token Ledger: finished ${imageLabel} PNG.\n`);
814
+ return [
815
+ `Wrote ${options.report ? "report" : "trend image"}: ${outputPath}`,
816
+ `Range: ${bounds.startDateString} through ${bounds.endDateString} (${bounds.timeZone})`,
817
+ ].join("\n");
818
+ }
819
+ return output;
539
820
  }
540
821
 
541
822
  function shouldUseInteractive(options) {
542
823
  return Boolean(
543
824
  !options.static &&
825
+ options.view !== "trend" &&
544
826
  !options.plain &&
827
+ !options.legacyPlot &&
545
828
  !process.env.NO_COLOR &&
546
829
  process.stdin.isTTY &&
547
830
  process.stdout.isTTY,
@@ -555,7 +838,14 @@ async function runInteractive(options) {
555
838
  const snapshot = await loadSnapshot(options);
556
839
  const events = filterDayEvents(snapshot, bounds);
557
840
  if (events.length === 0) {
558
- process.stdout.write(`${emptyState(options, snapshot, bounds)}\n`);
841
+ const rangeDescription = options.range === "week"
842
+ ? `${bounds.startDateString} through ${bounds.endDateString}`
843
+ : bounds.dateString;
844
+ process.stdout.write([
845
+ `No model-call events found for ${rangeDescription} (${bounds.timeZone}).`,
846
+ `Source: ${sourceLabel(options.input, snapshot)}`,
847
+ "",
848
+ ].join("\n"));
559
849
  return;
560
850
  }
561
851
  const allRows = aggregateProjects(snapshot, events, options);
@@ -580,12 +870,10 @@ async function main() {
580
870
  if (shouldUseInteractive(options)) {
581
871
  await runInteractive(options);
582
872
  } else {
583
- process.stdout.write(`${await run({ ...options, static: true })}\n`);
873
+ process.stdout.write(`${await run(options)}\n`);
584
874
  }
585
875
  } catch (error) {
586
- process.stderr.write(
587
- `Token Ledger CLI failed: ${sanitizeTerminalText(error.message)}\n\n${usage()}\n`,
588
- );
876
+ process.stderr.write(`Token Ledger CLI failed: ${error.message}\n\n${usage()}\n`);
589
877
  process.exitCode = 1;
590
878
  }
591
879
  }