claude-cup 0.4.1 → 0.7.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/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
@@ -56,6 +56,7 @@ export function leaderboardColorFor(pct) {
56
56
 
57
57
  const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
58
58
  const fmt = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n ?? 0));
59
+ const fmtRank = (n) => Number(n || 0).toLocaleString('en-US');
59
60
 
60
61
  function countdown(resetsAt) {
61
62
  if (!resetsAt) return '';
@@ -235,8 +236,8 @@ function drawMascot(g, x, y, pose = {}) {
235
236
  }
236
237
 
237
238
  // ---- football animations for the mascot ----
238
- export const MASCOT_ANIMS = ['kick', 'juggle', 'header', 'dribble', 'goal'];
239
- export const ANIM_FRAMES = { kick: 16, juggle: 20, header: 16, dribble: 20, goal: 18 };
239
+ export const MASCOT_ANIMS = ['kick', 'juggle', 'header', 'dribble', 'goal', 'rainbow', 'bicycle', 'volley'];
240
+ export const ANIM_FRAMES = { kick: 16, juggle: 20, header: 16, dribble: 20, goal: 18, rainbow: 20, bicycle: 22, volley: 18 };
240
241
  const CONFETTI = [
241
242
  [106, 155, 204], // sky
242
243
  [120, 140, 93], // olive
@@ -330,6 +331,139 @@ export function footballScene(anim, mx, my, cols) {
330
331
  y: my + ((dy0 + Math.floor(f / 2)) % 5), // rows my..my+4
331
332
  color: CONFETTI[(i + f) % CONFETTI.length],
332
333
  }));
334
+ } else if (anim.name === 'rainbow') {
335
+ // Brazilian rainbow flick: ball goes from back heel, arcs OVER the head,
336
+ // lands in front. Path designed to avoid eye cells (mx+3,my+2) & (mx+8,my+2).
337
+ // Lookup table -- every frame has an explicit (dx, dy, pose) entry so the
338
+ // arc is glitch-free and reads cleanly even at 140ms ticks.
339
+ // NB: no shiftX during the arc. shiftX moves the eye columns
340
+ // (left eye = mx+3+shiftX, right eye = mx+8+shiftX), which would otherwise
341
+ // place an eye exactly where the ball or trail crosses my+2.
342
+ const path = [
343
+ // f 0-3: idle, ball at back heel
344
+ { dx: 1, dy: 4, pose: { legPhase: 0 } },
345
+ { dx: 1, dy: 4, pose: { legPhase: 1 } },
346
+ { dx: 1, dy: 4, pose: { legPhase: 0 } },
347
+ { dx: 1, dy: 4, pose: { legPhase: 1 } },
348
+ // f 4: heel flick
349
+ { dx: 1, dy: 3, pose: {} },
350
+ // f 5-11: arc OVER the head, skipping eye cells (no shiftX)
351
+ { dx: 2, dy: 2, pose: {} }, // 5 -- past hip (mx+2 != mx+3 eye)
352
+ { dx: 4, dy: 1, pose: { armTwitch: 1 } }, // 6 -- rising, above eye row
353
+ { dx: 5, dy: 0, pose: { armTwitch: 1 } }, // 7 -- peak left
354
+ { dx: 7, dy: 0, pose: { armTwitch: 1 } }, // 8 -- peak right
355
+ { dx: 9, dy: 1, pose: { armTwitch: 1 } }, // 9 -- descending, above eye row
356
+ { dx: 10, dy: 2, pose: {} }, // 10 -- past right eye (mx+10 != mx+8)
357
+ { dx: 12, dy: 3, pose: {} }, // 11 -- approaching ground
358
+ // f 12: landed in front
359
+ { dx: 13, dy: 4, pose: { legPhase: 1 } },
360
+ // f 13-19: settles, mascot returns to neutral (safe to shift now -- ball far from eye row)
361
+ { dx: 13, dy: 4, pose: { shiftX: 1, legPhase: 0 } },
362
+ { dx: 13, dy: 4, pose: { shiftX: 1, legPhase: 1 } },
363
+ { dx: 13, dy: 4, pose: { legPhase: 0 } },
364
+ { dx: 13, dy: 4, pose: { legPhase: 1 } },
365
+ { dx: 13, dy: 4, pose: { legPhase: 0 } },
366
+ { dx: 13, dy: 4, pose: { legPhase: 1 } },
367
+ { dx: 13, dy: 4, pose: { legPhase: 0 } },
368
+ ];
369
+ const step = path[Math.min(f, path.length - 1)];
370
+ out.ball = { x: mx + step.dx, y: my + step.dy };
371
+ out.pose = step.pose;
372
+ // Trail: a single dim dot one cell behind the rising arc (frames 5-11 only),
373
+ // placed on the same y so it never crosses the eye row.
374
+ if (f >= 5 && f <= 11) {
375
+ const prev = path[f - 1];
376
+ out.trail = [{ x: mx + prev.dx, y: my + prev.dy }];
377
+ }
378
+ } else if (anim.name === 'bicycle') {
379
+ // Bicycle kick: ball drops from upper right, mascot leaps (armsUp signals
380
+ // the backwards leap), strikes mid-air, ball ROCKETS out left with trail.
381
+ // Path skips eye cells (mx+3,my+2) and (mx+8,my+2) by going through my+1
382
+ // when crossing eye columns.
383
+ const path = [
384
+ // f 0-2: ball drops in from upper right
385
+ { ball: { dx: 15, dy: 0 }, pose: {} },
386
+ { ball: { dx: 14, dy: 1 }, pose: {} },
387
+ { ball: { dx: 13, dy: 2 }, pose: {} },
388
+ // f 3-4: mascot leaps (armsUp), ball at chest height
389
+ { ball: { dx: 13, dy: 3 }, pose: { armsUp: true } },
390
+ { ball: { dx: 13, dy: 3 }, pose: { armsUp: true } },
391
+ // f 5: STRIKE -- armsUp + kick
392
+ { ball: { dx: 12, dy: 3 }, pose: { armsUp: true, kick: 1 } },
393
+ // f 6-9: ball rockets out LEFT, picking altitude as it goes
394
+ { ball: { dx: 10, dy: 2 }, pose: { armsUp: true, kick: 1 } }, // 6
395
+ { ball: { dx: 7, dy: 2 }, pose: { armsUp: true } }, // 7 -- skips mx+8 col
396
+ { ball: { dx: 4, dy: 1 }, pose: { armsUp: true } }, // 8 -- above eye row
397
+ { ball: { dx: 1, dy: 1 }, pose: {} }, // 9 -- almost out
398
+ // f 10: ball exits off-screen left (clipped by drawFootball bounds)
399
+ { ball: { dx: -2, dy: 1 }, pose: {} },
400
+ // f 11-15: mascot lands and steadies
401
+ { ball: null, pose: { legPhase: 1 } },
402
+ { ball: null, pose: { legPhase: 0 } },
403
+ { ball: null, pose: { legPhase: 1 } },
404
+ { ball: null, pose: { legPhase: 0 } },
405
+ { ball: null, pose: { legPhase: 1 } },
406
+ // f 16-21: hold pause (ta-da beat)
407
+ { ball: null, pose: {} },
408
+ { ball: null, pose: {} },
409
+ { ball: null, pose: {} },
410
+ { ball: null, pose: {} },
411
+ { ball: null, pose: {} },
412
+ { ball: null, pose: {} },
413
+ ];
414
+ const step = path[Math.min(f, path.length - 1)];
415
+ out.pose = step.pose;
416
+ if (step.ball) out.ball = { x: mx + step.ball.dx, y: my + step.ball.dy };
417
+ // Trail: two dots EAST of the ball on the same y (frames 6-9 only).
418
+ // Same-y avoids ever landing on the eye row.
419
+ if (f >= 6 && f <= 9 && step.ball) {
420
+ const by = my + step.ball.dy;
421
+ out.trail = [
422
+ { x: mx + step.ball.dx + 2, y: by },
423
+ { x: mx + step.ball.dx + 4, y: by },
424
+ ];
425
+ }
426
+ } else if (anim.name === 'volley') {
427
+ // Side-foot volley: ball arcs in from the left at chest height, mascot
428
+ // strikes a flat volley out to the right. Existing pose.kick (right foot)
429
+ // matches the right-going strike direction.
430
+ const path = [
431
+ // f 0-2: ball arcs in from off-screen left, above eye row
432
+ { ball: { dx: -4, dy: 1 }, pose: {} },
433
+ { ball: { dx: -1, dy: 1 }, pose: {} },
434
+ { ball: { dx: 2, dy: 1 }, pose: {} },
435
+ // f 3-4: mascot leans into it, ball descends to strike zone
436
+ { ball: { dx: 5, dy: 2 }, pose: { shiftX: 1 } }, // 3 -- between eyes safely
437
+ { ball: { dx: 9, dy: 3 }, pose: { shiftX: 1 } }, // 4 -- below eye row
438
+ // f 5: STRIKE
439
+ { ball: { dx: 12, dy: 3 }, pose: { shiftX: 1, kick: 1, armTwitch: 1 } },
440
+ // f 6-9: rocketing right, picking altitude
441
+ { ball: { dx: 14, dy: 2 }, pose: { kick: 1 } },
442
+ { ball: { dx: 17, dy: 1 }, pose: {} },
443
+ { ball: { dx: 20, dy: 0 }, pose: {} },
444
+ { ball: { dx: 23, dy: 0 }, pose: {} },
445
+ // f 10-12: ball exits off-screen right (clipped by drawFootball bounds)
446
+ { ball: { dx: 27, dy: 0 }, pose: {} },
447
+ { ball: { dx: 31, dy: 0 }, pose: {} },
448
+ { ball: { dx: 36, dy: 0 }, pose: {} },
449
+ // f 13-17: mascot resumes tippy-tap
450
+ { ball: null, pose: { legPhase: 1 } },
451
+ { ball: null, pose: { legPhase: 0 } },
452
+ { ball: null, pose: { legPhase: 1 } },
453
+ { ball: null, pose: { legPhase: 0 } },
454
+ { ball: null, pose: { legPhase: 1 } },
455
+ ];
456
+ const step = path[Math.min(f, path.length - 1)];
457
+ out.pose = step.pose;
458
+ if (step.ball) out.ball = { x: mx + step.ball.dx, y: my + step.ball.dy };
459
+ // Trail: two dots WEST of the ball on the same y (frames 6-10 only).
460
+ if (f >= 6 && f <= 10 && step.ball) {
461
+ const by = my + step.ball.dy;
462
+ out.trail = [
463
+ { x: mx + step.ball.dx - 2, y: by },
464
+ { x: mx + step.ball.dx - 4, y: by },
465
+ ];
466
+ }
333
467
  }
334
468
  return out;
335
469
  }
@@ -405,8 +539,10 @@ export function composeFrame(state) {
405
539
 
406
540
  g.text(2, 0, ` ${spark} claude cup `, { fg: CLAY, bold: true });
407
541
  const now = new Date();
542
+ const cupDays = typeof state.cupDaysLeft === 'number' ? state.cupDaysLeft : null;
543
+ const cupSuffix = cupDays === null ? '' : (cupDays > 0 ? ` \u00b7 cup d-${cupDays}` : ' \u00b7 cup ended');
408
544
  const dateLabel = ` ${now.toLocaleDateString('en-US', { weekday: 'long' })} \u00b7 ${now
409
- .toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} `.toLowerCase();
545
+ .toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}${cupSuffix} `.toLowerCase();
410
546
  g.text(cols - 3 - dateLabel.length, 0, dateLabel, eyebrow);
411
547
 
412
548
  // ---- the golden trophy ----
@@ -580,6 +716,59 @@ export function composeFrame(state) {
580
716
  g.text(sx + 15, y, String(stats.buildRate ?? 0), { fg: CLAY, bold: true });
581
717
  y++;
582
718
  }
719
+ // Leaderboard -- clean 3-row standings, name + rank only.
720
+ // ABOVE name #rank (gold, matches cup elite color)
721
+ // RANK you #rank (highlighted clay, the user's own row)
722
+ // BELOW name #rank
723
+ const lbState = state.leaderboard;
724
+ if (lbState && lbState.rank > 0) {
725
+ const tierColor = lbState.tier === 'Master' ? GOLD :
726
+ lbState.tier === 'Craftsman' ? CLAY :
727
+ lbState.tier === 'Builder' ? KRAFT : CLOUD;
728
+ const nameCol = 15;
729
+ const rankCol = 28; // wide enough to fit longest names neatly
730
+ // Fallback: derive neighbor ranks from user rank if cache predates nextRank/belowRank
731
+ const nextRank = lbState.nextRank || Math.max(1, lbState.rank - 1);
732
+ const belowRank = lbState.belowRank || (lbState.rank + 1);
733
+
734
+ // Row 1: ABOVE (or overtake banner if recent)
735
+ const justOvertook = lbState.passedTs && (Date.now() - lbState.passedTs) < 5000;
736
+ if (y < rows - 2) {
737
+ if (justOvertook && lbState.passedName && lbState.passedVerb) {
738
+ g.put(sx, y, '\u273b', { fg: CLAY });
739
+ const bannerTxt = `you just ${lbState.passedVerb} ${lbState.passedName}`.slice(0, colW - 2);
740
+ g.text(sx + 2, y, bannerTxt, { fg: CLAY, bold: true });
741
+ } else if (lbState.nextName) {
742
+ g.text(sx, y, 'ABOVE'.padEnd(nameCol), eyebrow);
743
+ g.text(sx + nameCol, y, String(lbState.nextName).slice(0, rankCol - nameCol - 1),
744
+ { fg: GOLD, bold: true });
745
+ g.text(sx + rankCol, y, `#${fmtRank(nextRank)}`, { fg: GOLD, bold: true });
746
+ }
747
+ y++;
748
+ }
749
+
750
+ // Row 2: RANK -- the user's own row, highlighted clay, rank + tier
751
+ if (y < rows - 2) {
752
+ g.text(sx, y, 'RANK'.padEnd(nameCol), { fg: CLAY, bold: true });
753
+ g.text(sx + nameCol, y, 'you', { fg: CLAY, bold: true });
754
+ const rankTxt = `#${fmtRank(lbState.rank)}`;
755
+ g.text(sx + rankCol, y, rankTxt, { fg: CLAY, bold: true });
756
+ const tierStart = sx + rankCol + rankTxt.length + 1;
757
+ const tierTxt = `\u00b7 ${lbState.tier}`;
758
+ if (tierStart + tierTxt.length <= sx + colW) {
759
+ g.text(tierStart, y, tierTxt, { fg: tierColor, bold: true });
760
+ }
761
+ y++;
762
+ }
763
+
764
+ // Row 3: BELOW
765
+ if (y < rows - 2 && lbState.belowName) {
766
+ g.text(sx, y, 'BELOW'.padEnd(nameCol), eyebrow);
767
+ g.text(sx + nameCol, y, String(lbState.belowName).slice(0, rankCol - nameCol - 1), { fg: CLOUD });
768
+ g.text(sx + rankCol, y, `#${fmtRank(belowRank)}`, { fg: CLOUD });
769
+ y++;
770
+ }
771
+ }
583
772
  if (y < rows - 2) {
584
773
  const GREEN = [76, 175, 80];
585
774
  g.text(sx, y, 'ECO MODE'.padEnd(15), { fg: GREEN });
@@ -593,22 +782,8 @@ export function composeFrame(state) {
593
782
  y++;
594
783
  }
595
784
  statRow('TOKENS TODAY', fmt(stats.totalTokens));
596
- const eff = stats.assistantMessages > 0 ? `${fmt(Math.round(stats.totalTokens / stats.assistantMessages))} tok/reply` : '\u2014';
597
- statRow('EFFICIENCY', eff);
598
- statRow('REPLIES', fmt(stats.assistantMessages));
599
785
  statRow('EST. COST', `$${(stats.cost ?? 0).toFixed(2)}`);
600
786
  statRow('BURN RATE', `${fmt(stats.burnRate)} tok/min`);
601
- if (usage?.fiveHour) {
602
- const tl = usage.timeLeft;
603
- if (!tl) statRow('TIME LEFT', 'measuring pace...', { fg: CLOUD, dim: true });
604
- else if (tl.outlasts) statRow('TIME LEFT', 'lasts past reset', { bold: true });
605
- else {
606
- const h = Math.floor(tl.minutes / 60);
607
- const m = Math.round(tl.minutes % 60);
608
- const dur = h > 0 ? `${h}h ${m}m` : `${m}m`;
609
- statRow('TIME LEFT', `~${dur} at this pace`, tl.minutes < 30 ? { fg: EMBER, bold: true } : { bold: true });
610
- }
611
- }
612
787
  if (usage?.sevenDay) {
613
788
  const sd = usage.sevenDay;
614
789
  const mini = 8;
@@ -643,7 +818,7 @@ export function composeFrame(state) {
643
818
  }
644
819
 
645
820
  // startTui uses surfaceRelFor + bodyHeightFor for the droplet/bubble physics.
646
- export function startTui({ aggregator, poller, watcher, eco, url, getPower, getLeaderboard, out = process.stdout }) {
821
+ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getLeaderboard, getCupDaysLeft, out = process.stdout }) {
647
822
  let colorMode = 24;
648
823
  try {
649
824
  if (out.hasColors && !out.hasColors(16777216)) colorMode = 256;
@@ -663,6 +838,7 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
663
838
  let mascotAnim = null; // { name, frame, frames } | null
664
839
  let lastAnim = null;
665
840
  let nextAnimAt = Date.now() + nextAnimDelay();
841
+ let prevLb = null; // last leaderboard snapshot, for celebration triggers
666
842
 
667
843
  const spawn = () => {
668
844
  if (falling.length > 26) return;
@@ -686,7 +862,8 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
686
862
  const seedCount = Math.min(24, snap.toolCalls + snap.assistantMessages);
687
863
  for (let i = 0; i < seedCount; i++) setTimeout(() => spawn(), 300 + i * 170);
688
864
 
689
- out.write('\x1b[?1049h\x1b[?25l'); // alt screen, hide cursor
865
+ // alt screen, hide cursor, set tab title (OSC 0 + 1 + 2 for maximum compatibility)
866
+ out.write('\x1b[?1049h\x1b[?25l\x1b]0;claude-cup\x07\x1b]1;claude-cup\x07\x1b]2;claude-cup\x07');
690
867
 
691
868
  const render = () => {
692
869
  frame++;
@@ -695,7 +872,21 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
695
872
  const hasLimit = usage && usage.fiveHour && (usage.status === 'ok' || usage.status === 'stale');
696
873
  const lb = typeof getLeaderboard === 'function' ? getLeaderboard() : null;
697
874
  const br = stats.buildRate ?? 0;
698
- const fill = Math.min(95, Math.round(Math.sqrt(br) * 42));
875
+ const fill = (lb && lb.percentile > 0)
876
+ ? lb.percentile
877
+ : Math.min(95, Math.round(Math.sqrt(br) * 42));
878
+
879
+ // Celebration: rank improved by 10+ OR tier upgraded -> fire goal animation
880
+ if (lb && lb.rank > 0) {
881
+ const tierOrder = { 'Builder': 0, 'Rising Builder': 1, 'Master Builder': 2, 'Elite Builder': 3 };
882
+ const tierUp = prevLb && tierOrder[lb.tier] > tierOrder[prevLb.tier];
883
+ const rankJump = prevLb && prevLb.rank > 0 && (prevLb.rank - lb.rank) >= 10;
884
+ if (!mascotAnim && (tierUp || rankJump)) {
885
+ mascotAnim = { name: 'goal', frame: 0, frames: ANIM_FRAMES.goal };
886
+ lastAnim = 'goal';
887
+ }
888
+ prevLb = lb;
889
+ }
699
890
 
700
891
  const rows = Math.min(out.rows || 24, 32);
701
892
  const bodyH = bodyHeightFor(rows);
@@ -767,10 +958,17 @@ export function startTui({ aggregator, poller, watcher, eco, url, getPower, getL
767
958
  url,
768
959
  colorMode,
769
960
  mascot: mascotAnim,
961
+ leaderboard: lb,
962
+ cupDaysLeft: typeof getCupDaysLeft === 'function' ? getCupDaysLeft() : null,
770
963
  powerLevel: power.powerLevel,
771
964
  richness: power.richness,
772
965
  });
773
966
  out.write('\x1b[H' + frameStr + '\x1b[J');
967
+ // Re-assert the tab/window title once per second (every 7th render at 140ms).
968
+ // Defends against VS Code / Cursor / shell prompts overwriting our title.
969
+ if (frame % 7 === 0) {
970
+ out.write('\x1b]0;claude-cup\x07\x1b]2;claude-cup\x07');
971
+ }
774
972
 
775
973
  // advance the animation for the next tick; reschedule when it finishes
776
974
  if (mascotAnim) {