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.
@@ -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 API_BASE = 'https://claude-cup-leaderboard.workers.dev';
11
- const TIMEOUT_MS = 5000;
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
- function getAnonId() {
14
- try {
15
- if (existsSync(ID_FILE)) return readFileSync(ID_FILE, 'utf8').trim();
16
- } catch { /* first run */ }
17
- const id = randomUUID();
18
- try {
19
- mkdirSync(JAR_DIR, { recursive: true });
20
- writeFileSync(ID_FILE, id);
21
- } catch { /* non-fatal */ }
22
- return id;
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 { percentile: 0, total: 0, rank: 0, buildRate: 0, ts: 0 };
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
- export async function submitAndRank(buildRate) {
41
- const id = getAnonId();
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
- try {
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 = Math.min(95, Math.round(Math.sqrt(br) * 42));
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
  });