@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.
- package/dist/cli.js +11 -5
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts +3 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -3
- package/dist/native.js.map +1 -1
- package/dist/sessions/opencode.d.ts +1 -0
- package/dist/sessions/opencode.d.ts.map +1 -1
- package/dist/sessions/opencode.js +16 -1
- package/dist/sessions/opencode.js.map +1 -1
- package/dist/sessions/types.d.ts +2 -4
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +2 -4
- package/dist/sessions/types.js.map +1 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +79 -3
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/Footer.d.ts +2 -0
- package/dist/tui/components/Footer.d.ts.map +1 -1
- package/dist/tui/components/Footer.js +54 -10
- package/dist/tui/components/Footer.js.map +1 -1
- package/dist/tui/components/StatsView.d.ts +2 -0
- package/dist/tui/components/StatsView.d.ts.map +1 -1
- package/dist/tui/components/StatsView.js +17 -1
- package/dist/tui/components/StatsView.js.map +1 -1
- package/dist/tui/config/settings.d.ts +3 -1
- package/dist/tui/config/settings.d.ts.map +1 -1
- package/dist/tui/config/settings.js +33 -6
- package/dist/tui/config/settings.js.map +1 -1
- package/dist/tui/hooks/useData.d.ts.map +1 -1
- package/dist/tui/hooks/useData.js +28 -1
- package/dist/tui/hooks/useData.js.map +1 -1
- package/dist/tui/types/index.d.ts +1 -0
- package/dist/tui/types/index.d.ts.map +1 -1
- package/dist/tui/types/index.js.map +1 -1
- package/dist/wrapped.d.ts +8 -0
- package/dist/wrapped.d.ts.map +1 -1
- package/dist/wrapped.js +184 -32
- package/dist/wrapped.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -6
- package/src/native.ts +8 -3
- package/src/sessions/opencode.ts +24 -1
- package/src/sessions/types.ts +6 -6
- package/src/tui/App.tsx +93 -2
- package/src/tui/components/Footer.tsx +167 -47
- package/src/tui/components/StatsView.tsx +25 -1
- package/src/tui/config/settings.ts +39 -6
- package/src/tui/hooks/useData.ts +24 -1
- package/src/tui/types/index.ts +1 -0
- 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
|
|
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
|
-
|
|
55
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
56
|
+
return validateSettings(raw);
|
|
29
57
|
}
|
|
30
58
|
} catch {
|
|
31
59
|
}
|
|
32
|
-
return { colorPalette: "
|
|
60
|
+
return { colorPalette: "blue", autoRefreshEnabled: false, autoRefreshMs: DEFAULT_AUTO_REFRESH_MS };
|
|
33
61
|
}
|
|
34
62
|
|
|
35
|
-
export function saveSettings(
|
|
36
|
-
|
|
37
|
-
|
|
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 {
|
package/src/tui/hooks/useData.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|
package/src/tui/types/index.ts
CHANGED
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}/
|
|
68
|
-
"Claude Code": `${ASSETS_BASE_URL}/
|
|
69
|
-
"Codex CLI": `${ASSETS_BASE_URL}/
|
|
70
|
-
"Gemini CLI": `${ASSETS_BASE_URL}/
|
|
71
|
-
"Cursor IDE": `${ASSETS_BASE_URL}/
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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 = `
|
|
596
|
-
const logoPath = await fetchAndCacheImage(
|
|
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
|
-
|
|
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(
|
|
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, {
|
|
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;
|