@tokscale/cli 1.0.13 → 1.0.15

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.
Files changed (51) hide show
  1. package/dist/cli.js +11 -5
  2. package/dist/cli.js.map +1 -1
  3. package/dist/native.d.ts +3 -0
  4. package/dist/native.d.ts.map +1 -1
  5. package/dist/native.js +4 -3
  6. package/dist/native.js.map +1 -1
  7. package/dist/sessions/opencode.d.ts +1 -0
  8. package/dist/sessions/opencode.d.ts.map +1 -1
  9. package/dist/sessions/opencode.js +16 -1
  10. package/dist/sessions/opencode.js.map +1 -1
  11. package/dist/sessions/types.d.ts +2 -4
  12. package/dist/sessions/types.d.ts.map +1 -1
  13. package/dist/sessions/types.js +2 -4
  14. package/dist/sessions/types.js.map +1 -1
  15. package/dist/tui/App.d.ts.map +1 -1
  16. package/dist/tui/App.js +79 -3
  17. package/dist/tui/App.js.map +1 -1
  18. package/dist/tui/components/Footer.d.ts +2 -0
  19. package/dist/tui/components/Footer.d.ts.map +1 -1
  20. package/dist/tui/components/Footer.js +54 -10
  21. package/dist/tui/components/Footer.js.map +1 -1
  22. package/dist/tui/components/StatsView.d.ts +2 -0
  23. package/dist/tui/components/StatsView.d.ts.map +1 -1
  24. package/dist/tui/components/StatsView.js +17 -1
  25. package/dist/tui/components/StatsView.js.map +1 -1
  26. package/dist/tui/config/settings.d.ts +3 -1
  27. package/dist/tui/config/settings.d.ts.map +1 -1
  28. package/dist/tui/config/settings.js +33 -6
  29. package/dist/tui/config/settings.js.map +1 -1
  30. package/dist/tui/hooks/useData.d.ts.map +1 -1
  31. package/dist/tui/hooks/useData.js +28 -1
  32. package/dist/tui/hooks/useData.js.map +1 -1
  33. package/dist/tui/types/index.d.ts +1 -0
  34. package/dist/tui/types/index.d.ts.map +1 -1
  35. package/dist/tui/types/index.js.map +1 -1
  36. package/dist/wrapped.d.ts +8 -0
  37. package/dist/wrapped.d.ts.map +1 -1
  38. package/dist/wrapped.js +184 -32
  39. package/dist/wrapped.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli.ts +14 -6
  42. package/src/native.ts +8 -3
  43. package/src/sessions/opencode.ts +24 -1
  44. package/src/sessions/types.ts +6 -6
  45. package/src/tui/App.tsx +93 -2
  46. package/src/tui/components/Footer.tsx +167 -47
  47. package/src/tui/components/StatsView.tsx +25 -1
  48. package/src/tui/config/settings.ts +39 -6
  49. package/src/tui/hooks/useData.ts +24 -1
  50. package/src/tui/types/index.ts +1 -0
  51. package/src/wrapped.ts +220 -42
@@ -1,6 +1,7 @@
1
1
  import { For, Show, createMemo, createSignal } from "solid-js";
2
2
  import type { TUIData } from "../hooks/useData.js";
3
3
  import type { ColorPaletteName } from "../config/themes.js";
4
+ import type { SortType, GridCell } from "../types/index.js";
4
5
  import { getPalette, getGradeColor } from "../config/themes.js";
5
6
  import { getModelColor } from "../utils/colors.js";
6
7
  import { formatTokens } from "../utils/format.js";
@@ -13,6 +14,7 @@ interface StatsViewProps {
13
14
  colorPalette: ColorPaletteName;
14
15
  width?: number;
15
16
  selectedDate?: string | null;
17
+ sortBy?: SortType;
16
18
  }
17
19
 
18
20
  const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
@@ -27,8 +29,30 @@ interface MonthLabel {
27
29
  export function StatsView(props: StatsViewProps) {
28
30
  const palette = () => getPalette(props.colorPalette);
29
31
  const isNarrowTerminal = () => isNarrow(props.width);
30
- const grid = () => props.data.contributionGrid;
32
+ const metric = () => props.sortBy ?? "tokens";
31
33
  const cellWidth = 2;
34
+
35
+ const grid = createMemo((): GridCell[][] => {
36
+ const contributions = props.data.contributions;
37
+ const baseGrid = props.data.contributionGrid;
38
+
39
+ const values = contributions.map(c => metric() === "tokens" ? c.tokens : c.cost);
40
+ const maxValue = Math.max(1, ...values);
41
+
42
+ const levelMap = new Map<string, number>();
43
+ for (const c of contributions) {
44
+ const value = metric() === "tokens" ? c.tokens : c.cost;
45
+ const level = value === 0 ? 0 : Math.min(4, Math.ceil((value / maxValue) * 4));
46
+ levelMap.set(c.date, level);
47
+ }
48
+
49
+ return baseGrid.map(row =>
50
+ row.map(cell => ({
51
+ date: cell.date,
52
+ level: cell.date ? (levelMap.get(cell.date) ?? 0) : 0,
53
+ }))
54
+ );
55
+ });
32
56
 
33
57
  const [clickedCell, setClickedCell] = createSignal<string | null>(null);
34
58
 
@@ -9,9 +9,36 @@ const CONFIG_FILE = join(CONFIG_DIR, "tui-settings.json");
9
9
  const CACHE_FILE = join(CACHE_DIR, "tui-data-cache.json");
10
10
 
11
11
  const CACHE_STALE_THRESHOLD_MS = 60 * 1000;
12
+ const MIN_AUTO_REFRESH_MS = 30000;
13
+ const MAX_AUTO_REFRESH_MS = 3600000;
14
+ const DEFAULT_AUTO_REFRESH_MS = 60000;
12
15
 
13
16
  interface TUISettings {
14
17
  colorPalette: string;
18
+ autoRefreshEnabled?: boolean;
19
+ autoRefreshMs?: number;
20
+ }
21
+
22
+ function validateSettings(raw: unknown): TUISettings {
23
+ const defaults: TUISettings = {
24
+ colorPalette: "blue",
25
+ autoRefreshEnabled: false,
26
+ autoRefreshMs: DEFAULT_AUTO_REFRESH_MS
27
+ };
28
+
29
+ if (!raw || typeof raw !== "object") return defaults;
30
+
31
+ const obj = raw as Record<string, unknown>;
32
+
33
+ const colorPalette = typeof obj.colorPalette === "string" ? obj.colorPalette : defaults.colorPalette;
34
+ const autoRefreshEnabled = typeof obj.autoRefreshEnabled === "boolean" ? obj.autoRefreshEnabled : defaults.autoRefreshEnabled;
35
+
36
+ let autoRefreshMs = defaults.autoRefreshMs;
37
+ if (typeof obj.autoRefreshMs === "number" && Number.isFinite(obj.autoRefreshMs)) {
38
+ autoRefreshMs = Math.min(MAX_AUTO_REFRESH_MS, Math.max(MIN_AUTO_REFRESH_MS, obj.autoRefreshMs));
39
+ }
40
+
41
+ return { colorPalette, autoRefreshEnabled, autoRefreshMs };
15
42
  }
16
43
 
17
44
  interface CachedTUIData {
@@ -25,18 +52,24 @@ interface CachedTUIData {
25
52
  export function loadSettings(): TUISettings {
26
53
  try {
27
54
  if (existsSync(CONFIG_FILE)) {
28
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
55
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
56
+ return validateSettings(raw);
29
57
  }
30
58
  } catch {
31
59
  }
32
- return { colorPalette: "green" };
60
+ return { colorPalette: "blue", autoRefreshEnabled: false, autoRefreshMs: DEFAULT_AUTO_REFRESH_MS };
33
61
  }
34
62
 
35
- export function saveSettings(settings: TUISettings): void {
36
- if (!existsSync(CONFIG_DIR)) {
37
- mkdirSync(CONFIG_DIR, { recursive: true });
63
+ export function saveSettings(updates: Partial<TUISettings>): void {
64
+ try {
65
+ if (!existsSync(CONFIG_DIR)) {
66
+ mkdirSync(CONFIG_DIR, { recursive: true });
67
+ }
68
+ const current = loadSettings();
69
+ const merged = { ...current, ...updates };
70
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
71
+ } catch {
38
72
  }
39
- writeFileSync(CONFIG_FILE, JSON.stringify(settings, null, 2));
40
73
  }
41
74
 
42
75
  function sourcesMatch(enabledSources: Set<string>, cachedSources: string[]): boolean {
@@ -245,9 +245,14 @@ async function loadData(enabledSources: Set<SourceType>, dateFilters?: DateFilte
245
245
  for (const d of dailyEntries) {
246
246
  if (d.cost > maxCost) maxCost = d.cost;
247
247
  }
248
+ let maxTokens = 1;
249
+ for (const d of dailyEntries) {
250
+ if (d.total > maxTokens) maxTokens = d.total;
251
+ }
248
252
  const contributions: ContributionDay[] = dailyEntries.map(d => ({
249
253
  date: d.date,
250
254
  cost: d.cost,
255
+ tokens: d.total,
251
256
  level: d.cost === 0 ? 0 : (Math.min(4, Math.ceil((d.cost / maxCost) * 4)) as 0 | 1 | 2 | 3 | 4),
252
257
  }));
253
258
 
@@ -452,13 +457,21 @@ export function useData(enabledSources: Accessor<Set<SourceType>>, dateFilters?:
452
457
  const [isRefreshing, setIsRefreshing] = createSignal(initialCachedData ? initialCacheIsStale : false);
453
458
 
454
459
  const [forceRefresh, setForceRefresh] = createSignal(false);
460
+ let pendingRefresh = false;
461
+ let currentRequestId = 0;
455
462
 
456
463
  const refresh = () => {
464
+ if (isRefreshing() || loading()) {
465
+ pendingRefresh = true;
466
+ return;
467
+ }
468
+ setIsRefreshing(true);
457
469
  setForceRefresh(true);
458
470
  setRefreshTrigger(prev => prev + 1);
459
471
  };
460
472
 
461
473
  const doLoad = (sources: Set<SourceType>, skipCacheCheck = false) => {
474
+ ++currentRequestId; // Invalidate any in-flight requests immediately
462
475
  const shouldSkipCache = skipCacheCheck || forceRefresh();
463
476
 
464
477
  if (!shouldSkipCache) {
@@ -488,17 +501,27 @@ export function useData(enabledSources: Accessor<Set<SourceType>>, dateFilters?:
488
501
  setForceRefresh(false);
489
502
  }
490
503
 
504
+ const requestId = currentRequestId;
491
505
  setError(null);
492
506
  loadData(sources, dateFilters)
493
507
  .then((freshData) => {
508
+ if (requestId !== currentRequestId) return;
494
509
  setData(freshData);
495
510
  saveCachedData(freshData, sources);
496
511
  })
497
- .catch((e) => setError(e.message))
512
+ .catch((e: unknown) => {
513
+ if (requestId !== currentRequestId) return;
514
+ setError(e instanceof Error ? e.message : String(e));
515
+ })
498
516
  .finally(() => {
517
+ if (requestId !== currentRequestId) return;
499
518
  setLoading(false);
500
519
  setIsRefreshing(false);
501
520
  setLoadingPhase("complete");
521
+ if (pendingRefresh) {
522
+ pendingRefresh = false;
523
+ refresh();
524
+ }
502
525
  });
503
526
  };
504
527
 
@@ -31,6 +31,7 @@ export interface DailyEntry {
31
31
  export interface ContributionDay {
32
32
  date: string;
33
33
  cost: number;
34
+ tokens: number;
34
35
  level: number;
35
36
  }
36
37
 
package/src/wrapped.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "./native.js";
12
12
  import { PricingFetcher } from "./pricing.js";
13
13
  import { syncCursorCache, loadCursorCredentials } from "./cursor.js";
14
+ import { loadCredentials } from "./credentials.js";
14
15
  import type { SourceType } from "./graph-types.js";
15
16
 
16
17
  interface WrappedData {
@@ -24,6 +25,7 @@ interface WrappedData {
24
25
  longestStreak: number;
25
26
  topModels: Array<{ name: string; cost: number; tokens: number }>;
26
27
  topClients: Array<{ name: string; cost: number; tokens: number }>;
28
+ topAgents?: Array<{ name: string; cost: number; tokens: number; messages: number }>;
27
29
  contributions: Array<{ date: string; level: 0 | 1 | 2 | 3 | 4 }>;
28
30
  totalMessages: number;
29
31
  }
@@ -33,6 +35,8 @@ export interface WrappedOptions {
33
35
  year?: string;
34
36
  sources?: SourceType[];
35
37
  short?: boolean;
38
+ includeAgents?: boolean;
39
+ pinSisyphus?: boolean;
36
40
  }
37
41
 
38
42
  const SCALE = 2;
@@ -61,16 +65,63 @@ const SOURCE_DISPLAY_NAMES: Record<string, string> = {
61
65
  cursor: "Cursor IDE",
62
66
  };
63
67
 
64
- const ASSETS_BASE_URL = "https://tokscale.ai/assets";
68
+ const ASSETS_BASE_URL = "https://tokscale.ai/assets/logos";
69
+
70
+ const PINNED_AGENTS = ["Sisyphus", "Planner-Sisyphus"];
71
+
72
+ function normalizeAgentName(agent: string): string {
73
+ const agentLower = agent.toLowerCase();
74
+
75
+ if (agentLower.includes("plan")) {
76
+ if (agentLower.includes("omo") || agentLower.includes("sisyphus")) {
77
+ return "Planner-Sisyphus";
78
+ }
79
+ return agent;
80
+ }
81
+
82
+ if (agentLower === "omo" || agentLower === "sisyphus") {
83
+ return "Sisyphus";
84
+ }
85
+
86
+ return agent;
87
+ }
65
88
 
66
89
  const CLIENT_LOGO_URLS: Record<string, string> = {
67
- "OpenCode": `${ASSETS_BASE_URL}/client-opencode.png`,
68
- "Claude Code": `${ASSETS_BASE_URL}/client-claude.jpg`,
69
- "Codex CLI": `${ASSETS_BASE_URL}/client-openai.jpg`,
70
- "Gemini CLI": `${ASSETS_BASE_URL}/client-gemini.png`,
71
- "Cursor IDE": `${ASSETS_BASE_URL}/client-cursor.jpg`,
90
+ "OpenCode": `${ASSETS_BASE_URL}/opencode.png`,
91
+ "Claude Code": `${ASSETS_BASE_URL}/claude.jpg`,
92
+ "Codex CLI": `${ASSETS_BASE_URL}/openai.jpg`,
93
+ "Gemini CLI": `${ASSETS_BASE_URL}/gemini.png`,
94
+ "Cursor IDE": `${ASSETS_BASE_URL}/cursor.jpg`,
72
95
  };
73
96
 
97
+ const PROVIDER_LOGO_URLS: Record<string, string> = {
98
+ "anthropic": `${ASSETS_BASE_URL}/claude.jpg`,
99
+ "openai": `${ASSETS_BASE_URL}/openai.jpg`,
100
+ "google": `${ASSETS_BASE_URL}/gemini.png`,
101
+ "xai": `${ASSETS_BASE_URL}/grok.jpg`,
102
+ "zai": `${ASSETS_BASE_URL}/zai.jpg`,
103
+ };
104
+
105
+ function getProviderFromModel(modelId: string): string | null {
106
+ const lower = modelId.toLowerCase();
107
+ if (lower.includes("claude") || lower.includes("opus") || lower.includes("sonnet") || lower.includes("haiku")) {
108
+ return "anthropic";
109
+ }
110
+ if (lower.includes("gpt") || lower.includes("o1") || lower.includes("o3") || lower.includes("codex")) {
111
+ return "openai";
112
+ }
113
+ if (lower.includes("gemini")) {
114
+ return "google";
115
+ }
116
+ if (lower.includes("grok")) {
117
+ return "xai";
118
+ }
119
+ if (lower.includes("glm") || lower.includes("pickle")) {
120
+ return "zai";
121
+ }
122
+ return null;
123
+ }
124
+
74
125
  const TOKSCALE_LOGO_SVG_URL = "https://tokscale.ai/tokscale-logo.svg";
75
126
  const TOKSCALE_LOGO_PNG_SIZE = 400;
76
127
 
@@ -171,7 +222,7 @@ async function loadWrappedData(options: WrappedOptions): Promise<WrappedData> {
171
222
  pricingFetcher.fetchPricing(),
172
223
  includeCursor && loadCursorCredentials() ? syncCursorCache() : Promise.resolve({ synced: false, rows: 0 }),
173
224
  localSources.length > 0
174
- ? parseLocalSourcesAsync({ sources: localSources, since, until, year })
225
+ ? parseLocalSourcesAsync({ sources: localSources, since, until, year, forceTypescript: options.includeAgents })
175
226
  : Promise.resolve({ messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, processingTimeMs: 0 } as ParsedMessages),
176
227
  ]);
177
228
 
@@ -247,6 +298,54 @@ async function loadWrappedData(options: WrappedOptions): Promise<WrappedData> {
247
298
  .sort((a, b) => b.cost - a.cost)
248
299
  .slice(0, 3);
249
300
 
301
+ let topAgents: Array<{ name: string; cost: number; tokens: number; messages: number }> | undefined;
302
+ if (options.includeAgents && localMessages) {
303
+ const pricingEntries = pricingFetcher.toPricingEntries();
304
+ const pricingMap = new Map(pricingEntries.map(p => [p.modelId, p.pricing]));
305
+
306
+ const agentMap = new Map<string, { cost: number; tokens: number; messages: number }>();
307
+ for (const msg of localMessages.messages) {
308
+ if (msg.source === "opencode" && msg.agent) {
309
+ const normalizedAgent = normalizeAgentName(msg.agent);
310
+ const existing = agentMap.get(normalizedAgent) || { cost: 0, tokens: 0, messages: 0 };
311
+
312
+ const msgTokens = msg.input + msg.output + msg.cacheRead + msg.cacheWrite + msg.reasoning;
313
+ const pricing = pricingMap.get(msg.modelId);
314
+ let msgCost = 0;
315
+ if (pricing) {
316
+ msgCost = (msg.input * pricing.inputCostPerToken) +
317
+ (msg.output * pricing.outputCostPerToken) +
318
+ (msg.cacheRead * (pricing.cacheReadInputTokenCost || 0)) +
319
+ (msg.cacheWrite * (pricing.cacheCreationInputTokenCost || 0));
320
+ }
321
+
322
+ agentMap.set(normalizedAgent, {
323
+ cost: existing.cost + msgCost,
324
+ tokens: existing.tokens + msgTokens,
325
+ messages: existing.messages + 1,
326
+ });
327
+ }
328
+ }
329
+
330
+ let agentsList = Array.from(agentMap.entries())
331
+ .map(([name, data]) => ({ name, ...data }));
332
+
333
+ if (options.pinSisyphus) {
334
+ const pinned = agentsList.filter(a => PINNED_AGENTS.includes(a.name));
335
+ const unpinned = agentsList.filter(a => !PINNED_AGENTS.includes(a.name));
336
+
337
+ pinned.sort((a, b) => PINNED_AGENTS.indexOf(a.name) - PINNED_AGENTS.indexOf(b.name));
338
+ unpinned.sort((a, b) => b.messages - a.messages);
339
+
340
+ agentsList = [...pinned, ...unpinned.slice(0, 2)];
341
+ } else {
342
+ agentsList.sort((a, b) => b.messages - a.messages);
343
+ agentsList = agentsList.slice(0, 3);
344
+ }
345
+
346
+ topAgents = agentsList.length > 0 ? agentsList : undefined;
347
+ }
348
+
250
349
  const maxCost = Math.max(...graph.contributions.map(c => c.totals.cost), 1);
251
350
  const contributions = graph.contributions.map(c => ({
252
351
  date: c.date,
@@ -269,6 +368,7 @@ async function loadWrappedData(options: WrappedOptions): Promise<WrappedData> {
269
368
  longestStreak,
270
369
  topModels,
271
370
  topClients,
371
+ topAgents,
272
372
  contributions,
273
373
  totalMessages: report.totalMessages,
274
374
  };
@@ -526,7 +626,7 @@ function formatDate(dateStr: string): string {
526
626
  return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
527
627
  }
528
628
 
529
- async function generateWrappedImage(data: WrappedData, options: { short?: boolean } = {}): Promise<Buffer> {
629
+ async function generateWrappedImage(data: WrappedData, options: { short?: boolean; includeAgents?: boolean; pinSisyphus?: boolean } = {}): Promise<Buffer> {
530
630
  await ensureFontsLoaded();
531
631
 
532
632
  const canvas = createCanvas(IMAGE_WIDTH, IMAGE_HEIGHT);
@@ -541,9 +641,19 @@ async function generateWrappedImage(data: WrappedData, options: { short?: boolea
541
641
 
542
642
  let yPos = PADDING + 24 * SCALE;
543
643
 
544
- ctx.fillStyle = COLORS.textSecondary;
545
- ctx.font = `${24 * SCALE}px Figtree, sans-serif`;
546
- ctx.fillText(`Tracking since ${formatDate(data.firstDay)}`, PADDING, yPos);
644
+ const credentials = loadCredentials();
645
+ const MAX_USERNAME_LENGTH = 30; // GitHub max is 39, but leave room for layout
646
+ const displayUsername = credentials?.username
647
+ ? credentials.username.length > MAX_USERNAME_LENGTH
648
+ ? credentials.username.substring(0, MAX_USERNAME_LENGTH - 1) + '…'
649
+ : credentials.username
650
+ : null;
651
+ const titleText = displayUsername
652
+ ? `@${displayUsername}'s Wrapped ${data.year}`
653
+ : `My Wrapped ${data.year}`;
654
+ ctx.fillStyle = COLORS.textPrimary;
655
+ ctx.font = `bold ${28 * SCALE}px Figtree, sans-serif`;
656
+ ctx.fillText(titleText, PADDING, yPos);
547
657
  yPos += 60 * SCALE;
548
658
 
549
659
  ctx.fillStyle = COLORS.textSecondary;
@@ -559,6 +669,9 @@ async function generateWrappedImage(data: WrappedData, options: { short?: boolea
559
669
  ctx.fillText(totalTokensDisplay, PADDING, yPos);
560
670
  yPos += 50 * SCALE + 40 * SCALE;
561
671
 
672
+ const logoSize = 32 * SCALE;
673
+ const logoRadius = 6 * SCALE;
674
+
562
675
  ctx.fillStyle = COLORS.textSecondary;
563
676
  ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
564
677
  ctx.fillText("Top Models", PADDING, yPos);
@@ -569,57 +682,118 @@ async function generateWrappedImage(data: WrappedData, options: { short?: boolea
569
682
  ctx.fillStyle = COLORS.textPrimary;
570
683
  ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
571
684
  ctx.fillText(`${i + 1}`, PADDING, yPos);
572
-
573
- ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
574
- ctx.fillText(formatModelName(model.name), PADDING + 40 * SCALE, yPos);
575
- yPos += 50 * SCALE;
576
- }
577
- yPos += 40 * SCALE;
578
685
 
579
- ctx.fillStyle = COLORS.textSecondary;
580
- ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
581
- ctx.fillText("Top Clients", PADDING, yPos);
582
- yPos += 48 * SCALE;
686
+ const provider = getProviderFromModel(model.name);
687
+ const providerLogoUrl = provider ? PROVIDER_LOGO_URLS[provider] : null;
688
+ let textX = PADDING + 40 * SCALE;
583
689
 
584
- const logoSize = 32 * SCALE;
585
-
586
- for (let i = 0; i < data.topClients.length; i++) {
587
- const client = data.topClients[i];
588
- ctx.fillStyle = COLORS.textPrimary;
589
- ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
590
- ctx.fillText(`${i + 1}`, PADDING, yPos);
591
-
592
- const logoUrl = CLIENT_LOGO_URLS[client.name];
593
- if (logoUrl) {
690
+ if (providerLogoUrl) {
594
691
  try {
595
- const filename = `client-${client.name.toLowerCase().replace(/\s+/g, "-")}@2x.png`;
596
- const logoPath = await fetchAndCacheImage(logoUrl, filename);
692
+ const filename = `provider-${provider}@2x.jpg`;
693
+ const logoPath = await fetchAndCacheImage(providerLogoUrl, filename);
597
694
  const logo = await loadImage(logoPath);
598
695
  const logoY = yPos - logoSize + 6 * SCALE;
599
-
600
696
  const logoX = PADDING + 40 * SCALE;
601
- const logoRadius = 6 * SCALE;
602
-
697
+
603
698
  ctx.save();
604
699
  drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
605
700
  ctx.clip();
606
701
  ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
607
702
  ctx.restore();
608
-
703
+
609
704
  drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
610
705
  ctx.strokeStyle = "#141A25";
611
706
  ctx.lineWidth = 1 * SCALE;
612
707
  ctx.stroke();
708
+
709
+ textX = logoX + logoSize + 12 * SCALE;
613
710
  } catch {
614
711
  }
615
712
  }
616
-
713
+
714
+ ctx.fillStyle = COLORS.textPrimary;
617
715
  ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
618
- ctx.fillText(client.name, PADDING + 40 * SCALE + logoSize + 12 * SCALE, yPos);
716
+ ctx.fillText(formatModelName(model.name), textX, yPos);
619
717
  yPos += 50 * SCALE;
620
718
  }
621
719
  yPos += 40 * SCALE;
622
720
 
721
+ if (options.includeAgents) {
722
+ ctx.fillStyle = COLORS.textSecondary;
723
+ ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
724
+ ctx.fillText("Top OpenCode Agents", PADDING, yPos);
725
+ yPos += 48 * SCALE;
726
+
727
+ const agents = data.topAgents || [];
728
+ const SISYPHUS_COLOR = "#00CED1";
729
+ let rankIndex = 1;
730
+
731
+ for (let i = 0; i < agents.length; i++) {
732
+ const agent = agents[i];
733
+ const isSisyphusAgent = PINNED_AGENTS.includes(agent.name);
734
+ const showWithDash = options.pinSisyphus && isSisyphusAgent;
735
+
736
+ ctx.fillStyle = showWithDash ? SISYPHUS_COLOR : COLORS.textPrimary;
737
+ ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
738
+ const prefix = showWithDash ? "•" : `${rankIndex}`;
739
+ ctx.fillText(prefix, PADDING, yPos);
740
+ if (!showWithDash) rankIndex++;
741
+
742
+ const nameX = PADDING + 40 * SCALE;
743
+ ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
744
+ ctx.fillStyle = isSisyphusAgent ? SISYPHUS_COLOR : COLORS.textPrimary;
745
+ ctx.fillText(agent.name, nameX, yPos);
746
+
747
+ const nameWidth = ctx.measureText(agent.name).width;
748
+ ctx.fillStyle = COLORS.textSecondary;
749
+ ctx.fillText(` (${agent.messages.toLocaleString()})`, nameX + nameWidth, yPos);
750
+
751
+ yPos += 50 * SCALE;
752
+ }
753
+ } else {
754
+ ctx.fillStyle = COLORS.textSecondary;
755
+ ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
756
+ ctx.fillText("Top Clients", PADDING, yPos);
757
+ yPos += 48 * SCALE;
758
+
759
+ for (let i = 0; i < data.topClients.length; i++) {
760
+ const client = data.topClients[i];
761
+ ctx.fillStyle = COLORS.textPrimary;
762
+ ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
763
+ ctx.fillText(`${i + 1}`, PADDING, yPos);
764
+
765
+ const logoUrl = CLIENT_LOGO_URLS[client.name];
766
+ if (logoUrl) {
767
+ try {
768
+ const filename = `client-${client.name.toLowerCase().replace(/\s+/g, "-")}@2x.png`;
769
+ const logoPath = await fetchAndCacheImage(logoUrl, filename);
770
+ const logo = await loadImage(logoPath);
771
+ const logoY = yPos - logoSize + 6 * SCALE;
772
+
773
+ const logoX = PADDING + 40 * SCALE;
774
+ const logoRadius = 6 * SCALE;
775
+
776
+ ctx.save();
777
+ drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
778
+ ctx.clip();
779
+ ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
780
+ ctx.restore();
781
+
782
+ drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
783
+ ctx.strokeStyle = "#141A25";
784
+ ctx.lineWidth = 1 * SCALE;
785
+ ctx.stroke();
786
+ } catch {
787
+ }
788
+ }
789
+
790
+ ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
791
+ ctx.fillText(client.name, PADDING + 40 * SCALE + logoSize + 12 * SCALE, yPos);
792
+ yPos += 50 * SCALE;
793
+ }
794
+ }
795
+ yPos += 40 * SCALE;
796
+
623
797
  const statsStartY = yPos;
624
798
  const statWidth = (leftWidth - PADDING * 2) / 2;
625
799
 
@@ -660,11 +834,15 @@ async function generateWrappedImage(data: WrappedData, options: { short?: boolea
660
834
 
661
835
  export async function generateWrapped(options: WrappedOptions): Promise<string> {
662
836
  const data = await loadWrappedData(options);
663
- const imageBuffer = await generateWrappedImage(data, { short: options.short });
664
-
837
+ const imageBuffer = await generateWrappedImage(data, {
838
+ short: options.short,
839
+ includeAgents: options.includeAgents,
840
+ pinSisyphus: options.pinSisyphus,
841
+ });
842
+
665
843
  const outputPath = options.output || `tokscale-${data.year}-wrapped.png`;
666
844
  const absolutePath = path.resolve(outputPath);
667
-
845
+
668
846
  fs.writeFileSync(absolutePath, imageBuffer);
669
847
 
670
848
  return absolutePath;