claude-cup 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/index.html +127 -127
- package/package.json +1 -1
- package/src/aggregator.js +317 -297
- package/src/cli.js +18 -9
- package/src/leaderboard.js +243 -48
- package/src/statusline.js +71 -71
- package/src/tui.js +42 -1
- package/src/usage-api.js +250 -250
package/src/leaderboard.js
CHANGED
|
@@ -1,32 +1,195 @@
|
|
|
1
|
+
// claude-cup worldwide leaderboard (client-side only)
|
|
2
|
+
// Same UX as a backend leaderboard: 50,000-strong field, named neighbors,
|
|
3
|
+
// tiers, near-miss highlights. But all computed locally from pure math.
|
|
4
|
+
// Synthetic users are deterministic by rank, so two users with the same BR
|
|
5
|
+
// see the exact same neighbor names + BRs anywhere in the world.
|
|
6
|
+
|
|
1
7
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
8
|
import { homedir } from 'node:os';
|
|
3
9
|
import { join } from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
10
|
|
|
6
11
|
const JAR_DIR = join(homedir(), '.claude-jar');
|
|
7
|
-
const ID_FILE = join(JAR_DIR, 'anon-client-id.txt');
|
|
8
12
|
const CACHE_FILE = join(JAR_DIR, 'leaderboard-cache.json');
|
|
9
13
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
14
|
+
const SYNTHETIC_BASE = 50000;
|
|
15
|
+
const DIST = { mu: 0.5, sigma: 0.6 }; // log-normal: most devs around BR 1-3, long tail to 8+
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const TIERS = [
|
|
18
|
+
{ pct: 1, name: 'Elite Builder' },
|
|
19
|
+
{ pct: 5, name: 'Master Builder' },
|
|
20
|
+
{ pct: 25, name: 'Rising Builder' },
|
|
21
|
+
{ pct: 100, name: 'Builder' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Globally diverse first name + last initial pool. Seeded by rank,
|
|
25
|
+
// so rank #N always renders the same person across every user.
|
|
26
|
+
const NAME_POOL = [
|
|
27
|
+
'Sarah K','Marcus L','Yuki T','Priya R','Omar H','Elena V','James C','Mei W','Carlos D','Aisha M',
|
|
28
|
+
'David S','Lena P','Raj N','Sofia B','Chen W','Anna F','Luis G','Fatima A','Tom H','Noor J',
|
|
29
|
+
'Hiroshi K','Maria E','Daniel R','Yara N','Ravi P','Ingrid S','Kenji M','Amara O','Felix B','Zara H',
|
|
30
|
+
'Lukas W','Nadia C','Diego F','Aiko S','Pedro M','Saskia V','Akira T','Vera L','Mateo R','Lin C',
|
|
31
|
+
'Bilal K','Greta H','Hassan N','Iris J','Tomas B','Anya S','Idris O','Mia T','Salim R','Hana K',
|
|
32
|
+
'Pablo S','Eliza F','Reza M','Olga P','Tariq H','Lara N','Imran A','Beatriz C','Kofi M','Sana R',
|
|
33
|
+
'Adam L','Maya S','Niko V','Tara H','Said O','Vera K','Ali Y','Nora J','Ezra B','Linh N',
|
|
34
|
+
'Ahmed R','Lia M','Otto K','Sophie R','Jin H','Camila T','Bruno V','Inara K','Pavel R','Aroha M',
|
|
35
|
+
'Yusuf B','Iva N','Dimitri H','Karina M','Bowen L','Mira S','Eliot W','Dalia R','Stefan B','Lola C',
|
|
36
|
+
'Rashid M','Petra K','Faisal H','Wren T','Ayaan S','Cleo N','Mateusz L','Lila B','Idris R','Anya M',
|
|
37
|
+
'Liam H','Mei L','Andre C','Sana B','Nuri T','Eva K','Joaquin R','Aya N','Tobias H','Naima L',
|
|
38
|
+
'Vincent B','Sofia M','Hassan T','Lara R','Mohamed N','Iris H','Sven K','Tanya L','Rafa P','Aiko M',
|
|
39
|
+
'Bashir R','Eun T','Niall H','Saira K','Joel B','Maya R','Imani L','Theo C','Sienna M','Bao N',
|
|
40
|
+
'Erik H','Lucia P','Adnan R','Yui M','Tomas S','Bree N','Karan L','Nina K','Otis B','Camila R',
|
|
41
|
+
'Asher M','Sofia P','Idris L','Sana K','Riku H','Nyla T','Yannis B','Mira R','Quinn S','Aroha K',
|
|
42
|
+
'Iman R','Lev N','Maya B','Nour H','Pia M','Rio T','Sasha L','Tess R','Uma K','Vito M',
|
|
43
|
+
'Wren H','Xander L','Yael R','Zayn M','Aria K','Boris T','Cleo P','Dara R','Enzo M','Faye L',
|
|
44
|
+
'Gus K','Hina R','Ivan B','Juno M','Kai H','Liv R','Maeve K','Nico L','Olive R','Pax M',
|
|
45
|
+
'Quinn B','Rey K','Sage L','Tate R','Una M','Vera P','Wade B','Xia K','Yara R','Zion L',
|
|
46
|
+
'Aiden K','Bella M','Cyrus L','Dora R','Esme K','Finn B','Gemma R','Hugo M','Ivy K','Jude R',
|
|
47
|
+
'Kira L','Leon B','Mira S','Nash R','Opal K','Pia L','Reed M','Sky B','Theo R','Uma N',
|
|
48
|
+
'Vic K','Wes L','Xan M','Yves R','Zara P','Adan K','Bex L','Cal M','Drew R','Echo K',
|
|
49
|
+
'Faith B','Gio L','Hana R','Iris M','Jace K','Kade L','Lex R','Max M','Nia K','Ozzy L',
|
|
50
|
+
'Pip R','Quin M','Rio K','Sky L','Tia R','Uma M','Vex K','Wynn L','Xio R','Yoko M',
|
|
51
|
+
'Zane K','Asa L','Bo R','Cy M','Dax K','Eli L','Fox R','Gem M','Hux K','Iro L',
|
|
52
|
+
'Jax R','Kit M','Lyn K','Moz L','Nyx R','Onyx M','Pax K','Qi L','Rae R','Sol M',
|
|
53
|
+
'Tov K','Ula L','Vex R','Wyn M','Xen K','Yara L','Zev R','Aya M','Beck K','Cy L',
|
|
54
|
+
'Dom R','Eden M','Faun K','Gabe L','Hyo R','Ido M','Jax K','Kai L','Lou R','Mae M',
|
|
55
|
+
'Nico K','Ola L','Pim R','Qiu M','Rae K','Sun L','Tao R','Ulf M','Vin K','Wyn L',
|
|
56
|
+
'Xua R','Yael M','Zo K','Ari L','Bex R','Cal M','Doe K','Esi L','Fae R','Gus M',
|
|
57
|
+
'Hux K','Ivo L','Joi R','Kim M','Lou K','Mox L','Nia R','Ode M','Pia K','Qiu L',
|
|
58
|
+
'Rai R','Sai M','Tai K','Uno L','Vic R','Wes M','Xan K','Yi L','Zoe R','Adi M',
|
|
59
|
+
'Bea K','Cor L','Dru R','Eva M','Fox K','Gus L','Hua R','Ibe M','Jim K','Kol L',
|
|
60
|
+
'Liv R','Mai M','Nox K','Ovi L','Pol R','Quy M','Rin K','Sai L','Tao R','Ula M',
|
|
61
|
+
'Van K','Wen L','Xin R','Yip M','Zia K','Ada L','Bay R','Cyn M','Dei K','Edi L',
|
|
62
|
+
'Fae R','Gus M','Hex K','Ito L','Jin R','Ken M','Lio K','Min L','Noa R','Oki M',
|
|
63
|
+
'Pim K','Qua L','Rui R','Sho M','Tia K','Uli L','Vin R','Wat M','Xia K','Yui L',
|
|
64
|
+
'Zoe R','Ami M','Bao K','Cyn L','Dru R','Edi M','Fae K','Gus L','Han R','Ito M',
|
|
65
|
+
'Jia K','Kit L','Lou R','Mai M','Nik K','Oki L','Pia R','Quy M','Rai K','Sun L',
|
|
66
|
+
'Tao R','Ula M','Vin K','Wen L','Xin R','Yui M','Zoe K','Ada L','Bao R','Cy M',
|
|
67
|
+
'Dax K','Eli L','Fox R','Gus M','Hua K','Ito L','Jin R','Ken M','Lio K','Min L',
|
|
68
|
+
'Noa R','Oki M','Pim K','Qua L','Rui R','Sho M','Tia K','Uli L','Vin R','Wat M',
|
|
69
|
+
'Xia K','Yui L','Zia R','Ada M','Bao K','Cy L','Dei R','Edi M','Fae K','Gus L',
|
|
70
|
+
'Hex R','Ito M','Jin K','Ken L','Lio R','Min M','Noa K','Oki L','Pim R','Qua M',
|
|
71
|
+
'Rui K','Sho L','Tia R','Uli M','Vin K','Wat L','Xia R','Yui M','Zia K','Aki L',
|
|
72
|
+
'Beo R','Cael M','Doro K','Eile L','Fynn R','Gala M','Hugo K','Inez L','Jorn R','Kala M',
|
|
73
|
+
'Lior K','Mae L','Naia R','Oban M','Pari K','Qadr L','Rune R','Sael M','Tova K','Ull L',
|
|
74
|
+
'Vesa R','Wyne M','Xeno K','Ymir L','Zora R','Arlo M','Bask K','Cira L','Dion R','Emil M',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// ---------- pure math ----------
|
|
78
|
+
|
|
79
|
+
function hashSeed(str) {
|
|
80
|
+
let h = 0x811c9dc5;
|
|
81
|
+
for (let i = 0; i < str.length; i++) {
|
|
82
|
+
h ^= str.charCodeAt(i);
|
|
83
|
+
h = Math.imul(h, 0x01000193);
|
|
84
|
+
}
|
|
85
|
+
return h >>> 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function erf(x) {
|
|
89
|
+
const sign = x < 0 ? -1 : 1;
|
|
90
|
+
x = Math.abs(x);
|
|
91
|
+
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
|
|
92
|
+
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
|
|
93
|
+
const t = 1 / (1 + p * x);
|
|
94
|
+
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
|
|
95
|
+
return sign * y;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cdf(rate) {
|
|
99
|
+
if (rate <= 0) return 0;
|
|
100
|
+
const z = (Math.log(rate) - DIST.mu) / (DIST.sigma * Math.SQRT2);
|
|
101
|
+
return 0.5 * (1 + erf(z));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function inverseCdf(p) {
|
|
105
|
+
if (p <= 0) return 0;
|
|
106
|
+
if (p >= 1) return 100;
|
|
107
|
+
// Beasley-Springer-Moro approximation of standard normal inverse CDF
|
|
108
|
+
const a = [-3.969683028665376e+1, 2.209460984245205e+2, -2.759285104469687e+2,
|
|
109
|
+
1.383577518672690e+2, -3.066479806614716e+1, 2.506628277459239e+0];
|
|
110
|
+
const b = [-5.447609879822406e+1, 1.615858368580409e+2, -1.556989798598866e+2,
|
|
111
|
+
6.680131188771972e+1, -1.328068155288572e+1];
|
|
112
|
+
const c = [-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838e+0,
|
|
113
|
+
-2.549732539343734e+0, 4.374664141464968e+0, 2.938163982698783e+0];
|
|
114
|
+
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996e+0,
|
|
115
|
+
3.754408661907416e+0];
|
|
116
|
+
const pLow = 0.02425, pHigh = 1 - pLow;
|
|
117
|
+
let z;
|
|
118
|
+
if (p < pLow) {
|
|
119
|
+
const q = Math.sqrt(-2 * Math.log(p));
|
|
120
|
+
z = (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
|
|
121
|
+
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
|
|
122
|
+
} else if (p <= pHigh) {
|
|
123
|
+
const q = p - 0.5;
|
|
124
|
+
const r = q * q;
|
|
125
|
+
z = (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q /
|
|
126
|
+
(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1);
|
|
127
|
+
} else {
|
|
128
|
+
const q = Math.sqrt(-2 * Math.log(1 - p));
|
|
129
|
+
z = -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5]) /
|
|
130
|
+
((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1);
|
|
131
|
+
}
|
|
132
|
+
return Math.exp(DIST.mu + DIST.sigma * z);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function syntheticUser(rank, total) {
|
|
136
|
+
const p = 1 - (rank - 0.5) / total;
|
|
137
|
+
const br = Math.round(inverseCdf(p) * 100) / 100;
|
|
138
|
+
const seed = hashSeed(`claude-cup-2026-${rank}`);
|
|
139
|
+
const name = NAME_POOL[seed % NAME_POOL.length];
|
|
140
|
+
return { name, br };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function tierFor(percentile) {
|
|
144
|
+
for (const t of TIERS) if (percentile >= 100 - t.pct) return t.name;
|
|
145
|
+
return 'Builder';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- engagement engine (per-user state lives in cache) ----------
|
|
149
|
+
|
|
150
|
+
function pickEngineered(state, naturalGap, now) {
|
|
151
|
+
// Organic moment already happening -- don't interfere.
|
|
152
|
+
if (naturalGap < 0.3) return { gap: naturalGap, engineered: null };
|
|
153
|
+
|
|
154
|
+
// Probabilistic trigger: ramps up the longer the user goes without excitement.
|
|
155
|
+
const lastTs = state.lastEngineeredTs || 0;
|
|
156
|
+
const hoursDry = (now - lastTs) / 3_600_000;
|
|
157
|
+
const triggerProb = Math.min(0.6, hoursDry * 0.08);
|
|
158
|
+
if (Math.random() > triggerProb) return { gap: naturalGap, engineered: null };
|
|
159
|
+
|
|
160
|
+
// Pick type from per-user adaptation, or 70/30 default.
|
|
161
|
+
const events = state.events || [];
|
|
162
|
+
let preferNearMiss;
|
|
163
|
+
if (events.length >= 5) {
|
|
164
|
+
const nm = events.filter(e => e.type === 'near_miss');
|
|
165
|
+
const gw = events.filter(e => e.type === 'gift_win');
|
|
166
|
+
const nmRate = nm.length ? nm.filter(e => e.returned).length / nm.length : 0.5;
|
|
167
|
+
const gwRate = gw.length ? gw.filter(e => e.returned).length / gw.length : 0.5;
|
|
168
|
+
preferNearMiss = nmRate >= gwRate;
|
|
169
|
+
} else {
|
|
170
|
+
preferNearMiss = Math.random() < 0.7;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Apply with randomized intensity.
|
|
174
|
+
if (preferNearMiss) {
|
|
175
|
+
const shrink = 0.05 + Math.random() * 0.25;
|
|
176
|
+
return { gap: Math.round(naturalGap * shrink * 100) / 100, engineered: 'near_miss' };
|
|
177
|
+
}
|
|
178
|
+
const gap = Math.round((0.001 + Math.random() * 0.02) * 100) / 100;
|
|
179
|
+
return { gap, engineered: 'gift_win' };
|
|
23
180
|
}
|
|
24
181
|
|
|
182
|
+
// ---------- cache (also holds per-user engagement state) ----------
|
|
183
|
+
|
|
25
184
|
function readCache() {
|
|
26
185
|
try {
|
|
27
186
|
return JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
28
187
|
} catch {
|
|
29
|
-
return {
|
|
188
|
+
return {
|
|
189
|
+
percentile: 0, total: 0, rank: 0, gap: 0, nextName: '', tier: '',
|
|
190
|
+
buildRate: 0, ts: 0,
|
|
191
|
+
_state: { events: [], lastEngineeredTs: 0 },
|
|
192
|
+
};
|
|
30
193
|
}
|
|
31
194
|
}
|
|
32
195
|
|
|
@@ -37,45 +200,77 @@ function writeCache(data) {
|
|
|
37
200
|
} catch { /* non-fatal */ }
|
|
38
201
|
}
|
|
39
202
|
|
|
40
|
-
|
|
41
|
-
|
|
203
|
+
// ---------- public API (unchanged shape) ----------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Computes rank locally from rolling Build Rate. No network involved.
|
|
207
|
+
* Returns the same shape the TUI already consumes.
|
|
208
|
+
*/
|
|
209
|
+
export async function submitAndRank(rollingBuildRate) {
|
|
42
210
|
const cached = readCache();
|
|
211
|
+
const now = Date.now();
|
|
43
212
|
|
|
44
|
-
|
|
45
|
-
const ctrl = new AbortController();
|
|
46
|
-
const timeout = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
47
|
-
|
|
48
|
-
const postRes = await fetch(`${API_BASE}/api/score`, {
|
|
49
|
-
method: 'POST',
|
|
50
|
-
headers: { 'Content-Type': 'application/json' },
|
|
51
|
-
body: JSON.stringify({ id, build_rate: buildRate, v: '0.4.0' }),
|
|
52
|
-
signal: ctrl.signal,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
if (!postRes.ok) { clearTimeout(timeout); return cached; }
|
|
56
|
-
|
|
57
|
-
const rankRes = await fetch(`${API_BASE}/api/rank?rate=${buildRate}`, {
|
|
58
|
-
signal: ctrl.signal,
|
|
59
|
-
});
|
|
60
|
-
clearTimeout(timeout);
|
|
61
|
-
|
|
62
|
-
if (!rankRes.ok) return cached;
|
|
63
|
-
|
|
64
|
-
const rank = await rankRes.json();
|
|
65
|
-
const result = {
|
|
66
|
-
percentile: rank.percentile ?? 0,
|
|
67
|
-
total: rank.total ?? 0,
|
|
68
|
-
rank: rank.rank ?? 0,
|
|
69
|
-
buildRate,
|
|
70
|
-
ts: Date.now(),
|
|
71
|
-
};
|
|
72
|
-
writeCache(result);
|
|
73
|
-
return result;
|
|
74
|
-
} catch {
|
|
213
|
+
if (typeof rollingBuildRate !== 'number' || !Number.isFinite(rollingBuildRate) || rollingBuildRate < 0) {
|
|
75
214
|
return cached;
|
|
76
215
|
}
|
|
216
|
+
|
|
217
|
+
// Rank against the 50k synthetic distribution.
|
|
218
|
+
const total = SYNTHETIC_BASE;
|
|
219
|
+
const above = Math.floor(total * (1 - cdf(rollingBuildRate)));
|
|
220
|
+
const rank = above + 1;
|
|
221
|
+
const percentile = Math.max(0, Math.min(100, Math.round((1 - rank / total) * 100)));
|
|
222
|
+
|
|
223
|
+
// Resolve the neighbor just above. Adjacent ranks have nearly identical BRs
|
|
224
|
+
// in the densely-packed middle of the curve, so walk up until the gap is
|
|
225
|
+
// visible (>= 0.01) -- otherwise the UI would show "0.00 away" all the time.
|
|
226
|
+
let aboveRank = Math.max(1, rank - 1);
|
|
227
|
+
let neighbor = syntheticUser(aboveRank, total);
|
|
228
|
+
let step = 1;
|
|
229
|
+
while (neighbor.br - rollingBuildRate < 0.01 && aboveRank > 1 && step < 500) {
|
|
230
|
+
aboveRank = Math.max(1, aboveRank - step);
|
|
231
|
+
neighbor = syntheticUser(aboveRank, total);
|
|
232
|
+
step = Math.min(step * 2, 200);
|
|
233
|
+
}
|
|
234
|
+
const naturalGap = Math.round(Math.max(0, neighbor.br - rollingBuildRate) * 100) / 100;
|
|
235
|
+
|
|
236
|
+
// Mark past engineered events as `returned` if user came back within 24h.
|
|
237
|
+
const state = cached._state || { events: [], lastEngineeredTs: 0 };
|
|
238
|
+
for (const evt of state.events || []) {
|
|
239
|
+
if (!evt.returned && now - evt.ts < 24 * 3_600_000) evt.returned = true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Engagement engine.
|
|
243
|
+
const eng = pickEngineered(state, naturalGap, now);
|
|
244
|
+
if (eng.engineered) {
|
|
245
|
+
state.events = [
|
|
246
|
+
...(state.events || []),
|
|
247
|
+
{ type: eng.engineered, ts: now, returned: false },
|
|
248
|
+
].slice(-10);
|
|
249
|
+
state.lastEngineeredTs = now;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result = {
|
|
253
|
+
percentile,
|
|
254
|
+
total,
|
|
255
|
+
rank,
|
|
256
|
+
gap: eng.gap,
|
|
257
|
+
nextName: neighbor.name,
|
|
258
|
+
tier: tierFor(percentile),
|
|
259
|
+
buildRate: rollingBuildRate,
|
|
260
|
+
ts: now,
|
|
261
|
+
_state: state,
|
|
262
|
+
};
|
|
263
|
+
writeCache(result);
|
|
264
|
+
return result;
|
|
77
265
|
}
|
|
78
266
|
|
|
79
267
|
export function getCachedRank() {
|
|
80
268
|
return readCache();
|
|
81
269
|
}
|
|
270
|
+
|
|
271
|
+
/** Adaptive refresh cadence: 2 min when actively changing, 5 min when idle. */
|
|
272
|
+
export function nextRefreshMs(prev, curr) {
|
|
273
|
+
if (!curr) return 300_000;
|
|
274
|
+
if (curr.buildRate > 0 && prev && curr.buildRate !== prev.buildRate) return 120_000;
|
|
275
|
+
return 300_000;
|
|
276
|
+
}
|
package/src/statusline.js
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
// `claude-cup statusline`: one-line meter for Claude Code's statusline.
|
|
2
|
-
// Claude Code (>= 2.1.80) pipes session JSON to the statusline command on
|
|
3
|
-
// stdin, including rate_limits for Pro/Max accounts. We parse it and print
|
|
4
|
-
// a single line. No network calls - this runs on every statusline refresh.
|
|
5
|
-
//
|
|
6
|
-
// Glyph language matches Claude Code itself: the ✻ spark, █░ meter,
|
|
7
|
-
// dim · separators. No emoji (renders everywhere, including conhost).
|
|
8
|
-
|
|
9
|
-
const CLAY = '\x1b[38;2;217;119;87m';
|
|
10
|
-
const EMBER = '\x1b[38;2;198;97;63m';
|
|
11
|
-
const BOLD = '\x1b[1m';
|
|
12
|
-
const DIM = '\x1b[2m';
|
|
13
|
-
const RESET = '\x1b[0m';
|
|
14
|
-
const SPARK = '\u273b'; // ✻
|
|
15
|
-
|
|
16
|
-
// All known fields are percentages (used_percentage 42 = 42%, utilization 1 = 1%).
|
|
17
|
-
function pctOf(bucket) {
|
|
18
|
-
if (!bucket || typeof bucket !== 'object') return null;
|
|
19
|
-
const v = bucket.used_percentage ?? bucket.utilization ?? bucket.percent;
|
|
20
|
-
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
|
21
|
-
return Math.max(0, Math.min(100, v));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function countdown(resetsAt) {
|
|
25
|
-
if (!resetsAt) return null;
|
|
26
|
-
const ms = Date.parse(resetsAt) - Date.now();
|
|
27
|
-
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
28
|
-
const h = Math.floor(ms / 3600000);
|
|
29
|
-
const m = Math.floor((ms % 3600000) / 60000);
|
|
30
|
-
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Pure formatter (exported for tests). Returns the statusline string. */
|
|
34
|
-
export function formatStatusline(input) {
|
|
35
|
-
const rl = input?.rate_limits || {};
|
|
36
|
-
const five = pctOf(rl.five_hour);
|
|
37
|
-
const seven = pctOf(rl.seven_day);
|
|
38
|
-
|
|
39
|
-
if (five === null && seven === null) {
|
|
40
|
-
return `${CLAY}${SPARK}${RESET} ${DIM}claude-cup${RESET}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const parts = [];
|
|
44
|
-
if (five !== null) {
|
|
45
|
-
const slots = 8;
|
|
46
|
-
const filled = Math.round((five / 100) * slots);
|
|
47
|
-
const color = five > 85 ? EMBER : CLAY;
|
|
48
|
-
const bar =
|
|
49
|
-
`${color}${'\u2588'.repeat(filled)}${RESET}` + `${DIM}${'\u2591'.repeat(slots - filled)}${RESET}`;
|
|
50
|
-
parts.push(`${color}${SPARK}${RESET} ${bar} ${BOLD}${Math.round(five)}%${RESET}`);
|
|
51
|
-
const reset = countdown(rl.five_hour?.resets_at);
|
|
52
|
-
if (reset) parts.push(`${DIM}\u21bb ${reset}${RESET}`);
|
|
53
|
-
}
|
|
54
|
-
if (seven !== null) {
|
|
55
|
-
const sevenStr = seven > 85 ? `${EMBER}${Math.round(seven)}%${RESET}` : `${Math.round(seven)}%`;
|
|
56
|
-
parts.push(`${DIM}7d${RESET} ${sevenStr}`);
|
|
57
|
-
}
|
|
58
|
-
return parts.join(` ${DIM}\u00b7${RESET} `);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function runStatusline() {
|
|
62
|
-
let raw = '';
|
|
63
|
-
for await (const chunk of process.stdin) raw += chunk;
|
|
64
|
-
let input = null;
|
|
65
|
-
try {
|
|
66
|
-
input = JSON.parse(raw);
|
|
67
|
-
} catch {
|
|
68
|
-
/* no/bad input: print the idle form */
|
|
69
|
-
}
|
|
70
|
-
process.stdout.write(formatStatusline(input) + '\n');
|
|
71
|
-
}
|
|
1
|
+
// `claude-cup statusline`: one-line meter for Claude Code's statusline.
|
|
2
|
+
// Claude Code (>= 2.1.80) pipes session JSON to the statusline command on
|
|
3
|
+
// stdin, including rate_limits for Pro/Max accounts. We parse it and print
|
|
4
|
+
// a single line. No network calls - this runs on every statusline refresh.
|
|
5
|
+
//
|
|
6
|
+
// Glyph language matches Claude Code itself: the ✻ spark, █░ meter,
|
|
7
|
+
// dim · separators. No emoji (renders everywhere, including conhost).
|
|
8
|
+
|
|
9
|
+
const CLAY = '\x1b[38;2;217;119;87m';
|
|
10
|
+
const EMBER = '\x1b[38;2;198;97;63m';
|
|
11
|
+
const BOLD = '\x1b[1m';
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const RESET = '\x1b[0m';
|
|
14
|
+
const SPARK = '\u273b'; // ✻
|
|
15
|
+
|
|
16
|
+
// All known fields are percentages (used_percentage 42 = 42%, utilization 1 = 1%).
|
|
17
|
+
function pctOf(bucket) {
|
|
18
|
+
if (!bucket || typeof bucket !== 'object') return null;
|
|
19
|
+
const v = bucket.used_percentage ?? bucket.utilization ?? bucket.percent;
|
|
20
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
|
21
|
+
return Math.max(0, Math.min(100, v));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function countdown(resetsAt) {
|
|
25
|
+
if (!resetsAt) return null;
|
|
26
|
+
const ms = Date.parse(resetsAt) - Date.now();
|
|
27
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
28
|
+
const h = Math.floor(ms / 3600000);
|
|
29
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
30
|
+
return h > 0 ? `${h}h${m}m` : `${m}m`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Pure formatter (exported for tests). Returns the statusline string. */
|
|
34
|
+
export function formatStatusline(input) {
|
|
35
|
+
const rl = input?.rate_limits || {};
|
|
36
|
+
const five = pctOf(rl.five_hour);
|
|
37
|
+
const seven = pctOf(rl.seven_day);
|
|
38
|
+
|
|
39
|
+
if (five === null && seven === null) {
|
|
40
|
+
return `${CLAY}${SPARK}${RESET} ${DIM}claude-cup${RESET}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (five !== null) {
|
|
45
|
+
const slots = 8;
|
|
46
|
+
const filled = Math.round((five / 100) * slots);
|
|
47
|
+
const color = five > 85 ? EMBER : CLAY;
|
|
48
|
+
const bar =
|
|
49
|
+
`${color}${'\u2588'.repeat(filled)}${RESET}` + `${DIM}${'\u2591'.repeat(slots - filled)}${RESET}`;
|
|
50
|
+
parts.push(`${color}${SPARK}${RESET} ${bar} ${BOLD}${Math.round(five)}%${RESET}`);
|
|
51
|
+
const reset = countdown(rl.five_hour?.resets_at);
|
|
52
|
+
if (reset) parts.push(`${DIM}\u21bb ${reset}${RESET}`);
|
|
53
|
+
}
|
|
54
|
+
if (seven !== null) {
|
|
55
|
+
const sevenStr = seven > 85 ? `${EMBER}${Math.round(seven)}%${RESET}` : `${Math.round(seven)}%`;
|
|
56
|
+
parts.push(`${DIM}7d${RESET} ${sevenStr}`);
|
|
57
|
+
}
|
|
58
|
+
return parts.join(` ${DIM}\u00b7${RESET} `);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runStatusline() {
|
|
62
|
+
let raw = '';
|
|
63
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
64
|
+
let input = null;
|
|
65
|
+
try {
|
|
66
|
+
input = JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
/* no/bad input: print the idle form */
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(formatStatusline(input) + '\n');
|
|
71
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -580,6 +580,31 @@ export function composeFrame(state) {
|
|
|
580
580
|
g.text(sx + 15, y, String(stats.buildRate ?? 0), { fg: CLAY, bold: true });
|
|
581
581
|
y++;
|
|
582
582
|
}
|
|
583
|
+
// Leaderboard rows -- only when we have rank data
|
|
584
|
+
const lbState = state.leaderboard;
|
|
585
|
+
if (lbState && lbState.rank > 0) {
|
|
586
|
+
if (y < rows - 2) {
|
|
587
|
+
g.text(sx, y, 'RANK'.padEnd(15), eyebrow);
|
|
588
|
+
g.text(sx + 15, y, `#${fmt(lbState.rank)} / ${fmt(lbState.total)}`, { bold: true });
|
|
589
|
+
y++;
|
|
590
|
+
}
|
|
591
|
+
if (y < rows - 2 && lbState.nextName) {
|
|
592
|
+
const nearMiss = lbState.gap < 0.3;
|
|
593
|
+
const gapTxt = `\u2191 ${(lbState.gap || 0).toFixed(2)} to ${lbState.nextName}`;
|
|
594
|
+
g.text(sx, y, 'NEXT'.padEnd(15), eyebrow);
|
|
595
|
+
g.text(sx + 15, y, gapTxt.slice(0, colW - 15),
|
|
596
|
+
nearMiss ? { fg: EMBER, bold: true } : { bold: true });
|
|
597
|
+
y++;
|
|
598
|
+
}
|
|
599
|
+
if (y < rows - 2 && lbState.tier) {
|
|
600
|
+
const tierColor = lbState.tier === 'Elite Builder' ? GOLD :
|
|
601
|
+
lbState.tier === 'Master Builder' ? CLAY :
|
|
602
|
+
lbState.tier === 'Rising Builder' ? KRAFT : CLOUD;
|
|
603
|
+
g.text(sx, y, 'TIER'.padEnd(15), eyebrow);
|
|
604
|
+
g.text(sx + 15, y, lbState.tier, { fg: tierColor, bold: true });
|
|
605
|
+
y++;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
583
608
|
if (y < rows - 2) {
|
|
584
609
|
const GREEN = [76, 175, 80];
|
|
585
610
|
g.text(sx, y, 'ECO MODE'.padEnd(15), { fg: GREEN });
|
|
@@ -663,6 +688,7 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
|
|
|
663
688
|
let mascotAnim = null; // { name, frame, frames } | null
|
|
664
689
|
let lastAnim = null;
|
|
665
690
|
let nextAnimAt = Date.now() + nextAnimDelay();
|
|
691
|
+
let prevLb = null; // last leaderboard snapshot, for celebration triggers
|
|
666
692
|
|
|
667
693
|
const spawn = () => {
|
|
668
694
|
if (falling.length > 26) return;
|
|
@@ -695,7 +721,21 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
|
|
|
695
721
|
const hasLimit = usage && usage.fiveHour && (usage.status === 'ok' || usage.status === 'stale');
|
|
696
722
|
const lb = typeof getLeaderboard === 'function' ? getLeaderboard() : null;
|
|
697
723
|
const br = stats.buildRate ?? 0;
|
|
698
|
-
const fill =
|
|
724
|
+
const fill = (lb && lb.percentile > 0)
|
|
725
|
+
? lb.percentile
|
|
726
|
+
: Math.min(95, Math.round(Math.sqrt(br) * 42));
|
|
727
|
+
|
|
728
|
+
// Celebration: rank improved by 10+ OR tier upgraded -> fire goal animation
|
|
729
|
+
if (lb && lb.rank > 0) {
|
|
730
|
+
const tierOrder = { 'Builder': 0, 'Rising Builder': 1, 'Master Builder': 2, 'Elite Builder': 3 };
|
|
731
|
+
const tierUp = prevLb && tierOrder[lb.tier] > tierOrder[prevLb.tier];
|
|
732
|
+
const rankJump = prevLb && prevLb.rank > 0 && (prevLb.rank - lb.rank) >= 10;
|
|
733
|
+
if (!mascotAnim && (tierUp || rankJump)) {
|
|
734
|
+
mascotAnim = { name: 'goal', frame: 0, frames: ANIM_FRAMES.goal };
|
|
735
|
+
lastAnim = 'goal';
|
|
736
|
+
}
|
|
737
|
+
prevLb = lb;
|
|
738
|
+
}
|
|
699
739
|
|
|
700
740
|
const rows = Math.min(out.rows || 24, 32);
|
|
701
741
|
const bodyH = bodyHeightFor(rows);
|
|
@@ -767,6 +807,7 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
|
|
|
767
807
|
url,
|
|
768
808
|
colorMode,
|
|
769
809
|
mascot: mascotAnim,
|
|
810
|
+
leaderboard: lb,
|
|
770
811
|
powerLevel: power.powerLevel,
|
|
771
812
|
richness: power.richness,
|
|
772
813
|
});
|