claude-cup 0.2.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/tui.js ADDED
@@ -0,0 +1,845 @@
1
+ // Terminal UI - drawn in Claude Code's own terminal design language:
2
+ // foreground glyphs only (no painted backgrounds, adapts to any theme),
3
+ // rounded hairline borders, dim/bold hierarchy, mono uppercase eyebrows,
4
+ // the ✻ spark, dot separators, and the clay accent spent sparingly.
5
+ // Works in any terminal, including the desktop app's pane (Ctrl+`).
6
+
7
+ const CLAY = [217, 119, 87];
8
+ const EMBER = [198, 97, 63];
9
+ const KRAFT = [212, 162, 127];
10
+ const BURNT = [157, 60, 40];
11
+ const IVORY = [250, 249, 245];
12
+ const CLOUD = [135, 134, 127];
13
+
14
+ export const TOKEN_COLORS = {
15
+ read: [106, 155, 204], // sky
16
+ edit: [120, 140, 93], // olive
17
+ terminal: [212, 162, 127], // kraft
18
+ web: [196, 102, 134], // fig
19
+ agent: [203, 202, 219], // heather
20
+ ai: [217, 119, 87], // clay
21
+ other: [176, 174, 165], // cloud medium
22
+ };
23
+
24
+ const SPARK_FRAMES = ['\u2722', '\u2733', '\u2736', '\u273b']; // ✢ ✳ ✶ ✻ - Claude Code's spinner
25
+ const SPARK_IDLE = '\u273b'; // ✻
26
+
27
+ export function liquidColorFor(pct) {
28
+ const stops = [
29
+ { p: 0, c: KRAFT },
30
+ { p: 55, c: CLAY },
31
+ { p: 85, c: EMBER },
32
+ { p: 100, c: BURNT },
33
+ ];
34
+ const p = Math.max(0, Math.min(100, pct));
35
+ for (let i = 1; i < stops.length; i++) {
36
+ if (p <= stops[i].p) {
37
+ const a = stops[i - 1];
38
+ const b = stops[i];
39
+ const t = (p - a.p) / (b.p - a.p || 1);
40
+ return a.c.map((v, k) => Math.round(v + (b.c[k] - v) * t));
41
+ }
42
+ }
43
+ return BURNT;
44
+ }
45
+
46
+ const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
47
+ const fmt = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n ?? 0));
48
+
49
+ function countdown(resetsAt) {
50
+ if (!resetsAt) return '';
51
+ const ms = Date.parse(resetsAt) - Date.now();
52
+ if (!Number.isFinite(ms) || ms <= 0) return 'resetting';
53
+ const h = Math.floor(ms / 3600000);
54
+ const m = Math.floor((ms % 3600000) / 60000);
55
+ if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`;
56
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
57
+ }
58
+
59
+ // ---- color emission with 256-color fallback ----
60
+ function colorSeq(plane, c, mode) {
61
+ if (mode === 24) return `${plane};2;${c[0]};${c[1]};${c[2]}`;
62
+ if (c[0] === c[1] && c[1] === c[2]) {
63
+ const g = 232 + Math.max(0, Math.min(23, Math.round(((c[0] - 8) / 247) * 23)));
64
+ return `${plane};5;${g}`;
65
+ }
66
+ const q = (v) => Math.round((v / 255) * 5);
67
+ return `${plane};5;${16 + 36 * q(c[0]) + 6 * q(c[1]) + q(c[2])}`;
68
+ }
69
+ const fgSeq = (c, mode) => colorSeq(38, c, mode);
70
+ const bgSeq = (c, mode) => colorSeq(48, c, mode);
71
+
72
+ class Grid {
73
+ constructor(cols, rows, colorMode = 24) {
74
+ this.cols = cols;
75
+ this.rows = rows;
76
+ this.mode = colorMode;
77
+ this.cells = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ ch: ' ' })));
78
+ }
79
+
80
+ put(x, y, ch, attr = {}) {
81
+ if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return;
82
+ this.cells[y][x] = { ch, ...attr };
83
+ }
84
+
85
+ text(x, y, str, attr = {}) {
86
+ for (let i = 0; i < str.length; i++) this.put(x + i, y, str[i], attr);
87
+ }
88
+
89
+ textRight(y, str, attr = {}) {
90
+ this.text(this.cols - 1 - str.length, y, str, attr);
91
+ }
92
+
93
+ toString() {
94
+ const out = [];
95
+ for (const row of this.cells) {
96
+ let line = '';
97
+ let prev = null;
98
+ for (const c of row) {
99
+ const key = `${c.fg ? c.fg.join() : ''}|${c.bg ? c.bg.join() : ''}|${c.bold ? 1 : 0}|${c.dim ? 1 : 0}`;
100
+ if (key !== prev) {
101
+ line += '\x1b[0m';
102
+ if (c.bold) line += '\x1b[1m';
103
+ if (c.dim) line += '\x1b[2m';
104
+ if (c.fg) line += `\x1b[${fgSeq(c.fg, this.mode)}m`;
105
+ if (c.bg) line += `\x1b[${bgSeq(c.bg, this.mode)}m`;
106
+ prev = key;
107
+ }
108
+ line += c.ch;
109
+ }
110
+ out.push(line.replace(/ +$/, '') + '\x1b[0m');
111
+ }
112
+ return out.join('\n');
113
+ }
114
+ }
115
+
116
+ // ---- trophy geometry (character cells) ----
117
+ // The vessel is the World Cup trophy: a big round globe (about 40% of the
118
+ // height, like the real one), two figures tapering into a short stem, and a
119
+ // two-step pedestal. Interior columns are 0..19; each row has its own
120
+ // interior span because the trophy's width varies with height.
121
+ // Total height H = bodyH + 5 rows:
122
+ // rel 0 globe cap (arc)
123
+ // rel 1..G-1 globe (bulges to 16 cols at its equator)
124
+ // rel G..G+1 figures tapering under the globe
125
+ // rel G+2..H-5 stem
126
+ // rel H-4 pedestal step (glass, not fillable)
127
+ // rel H-3 pedestal tier 1
128
+ // rel H-2 pedestal tier 2 (engraved)
129
+ // rel H-1 floor
130
+ const INNER_W = 20;
131
+
132
+ export function bodyHeightFor(rows) {
133
+ return Math.max(8, Math.min(16, rows - 10));
134
+ }
135
+
136
+ function profileFor(bodyH) {
137
+ const H = bodyH + 5;
138
+ const G = Math.max(4, Math.round(H * 0.38)); // cap + globe rows
139
+ return { H, G, slabRel: H - 4, baseA: H - 3, baseB: H - 2 };
140
+ }
141
+
142
+ function globeWidth(rel, G) {
143
+ const f = rel / (G - 1);
144
+ return 8 + 2 * Math.round(4 * Math.sin(Math.PI * Math.min(1, f)));
145
+ }
146
+
147
+ export function spanFor(rel, bodyH) {
148
+ const { H, G } = profileFor(bodyH);
149
+ if (rel >= 1 && rel <= G - 1) {
150
+ const w = globeWidth(rel, G);
151
+ const lo = (INNER_W - w) / 2;
152
+ return { lo, hi: lo + w - 1 };
153
+ }
154
+ if (rel >= G && rel <= H - 4) return { lo: 7, hi: 12 }; // figures + stem (+ step pass-through)
155
+ if (rel === H - 3) return { lo: 4, hi: 15 }; // pedestal tier 1
156
+ if (rel === H - 2) return { lo: 3, hi: 16 }; // pedestal tier 2
157
+ return { lo: 7, hi: 12 };
158
+ }
159
+
160
+ /** Fillable rows (rel indices, top to bottom). The slab row H-4 is glass. */
161
+ export function interiorRowsFor(bodyH) {
162
+ const H = bodyH + 5;
163
+ const rows = [1, 2, 3];
164
+ for (let r = 4; r <= H - 5; r++) rows.push(r);
165
+ rows.push(H - 3, H - 2);
166
+ return rows;
167
+ }
168
+
169
+ /** Rows flooded for a fill % - any real usage shows at least a puddle. */
170
+ export function fillRowsFor(bodyH, fill) {
171
+ const n = interiorRowsFor(bodyH).length;
172
+ const pct = Math.max(0, Math.min(100, fill));
173
+ return Math.max(pct >= 0.5 ? 1 : 0, Math.round((pct / 100) * n));
174
+ }
175
+
176
+ /** Top flooded row (rel index) for a fill %, or null when empty. */
177
+ export function surfaceRelFor(bodyH, fill) {
178
+ const interior = interiorRowsFor(bodyH);
179
+ const fillRows = fillRowsFor(bodyH, fill);
180
+ return fillRows > 0 ? interior[interior.length - fillRows] : null;
181
+ }
182
+
183
+ // Clawd, Claude Code's mascot - pixel replica of the welcome-screen art.
184
+ // Painted with cell BACKGROUNDS (like Claude Code does) so the body is one
185
+ // seamless clay shape with no gaps between rows, and the eyes are true black.
186
+ const INK = [20, 20, 19];
187
+
188
+ // pose = {} draws Clawd exactly as the static art. Optional fields animate
189
+ // limbs within the mascot's own 5 rows only (never the legend row above, so
190
+ // nothing in the UI is disturbed). The two eyes are always kept.
191
+ // shiftX horizontal body sway / lean (dribble, header, goal wiggle)
192
+ // legPhase 0|1 alternate leg columns (tippy-tap)
193
+ // kick 1 = extend the right foot toward the ball
194
+ // armTwitch raise the side arms a row (juggle)
195
+ // armsUp raise both arms up-and-out (goal celebration)
196
+ function drawMascot(g, x, y, pose = {}) {
197
+ const body = { bg: CLAY };
198
+ const eye = { bg: INK };
199
+ const px = x + (pose.shiftX || 0);
200
+ const cell = (cx, cy, attr = body) => g.put(px + cx, y + cy, ' ', attr);
201
+
202
+ cell(3, 0); // nubs
203
+ cell(9, 0);
204
+ for (let i = 1; i <= 11; i++) cell(i, 1); // body top
205
+ for (let i = 1; i <= 11; i++) cell(i, 2, i === 3 || i === 8 ? eye : body); // eyes row
206
+ for (let i = 0; i <= 12; i++) cell(i, 3); // body bottom + side arms
207
+ const legs = pose.legPhase === 1 ? [3, 5, 9, 11] : [2, 5, 8, 11];
208
+ for (const lx of legs) cell(lx, 4);
209
+
210
+ if (pose.armsUp) {
211
+ cell(0, 2);
212
+ cell(0, 1);
213
+ cell(12, 2);
214
+ cell(12, 1);
215
+ }
216
+ if (pose.armTwitch) {
217
+ cell(0, 2);
218
+ cell(12, 2);
219
+ }
220
+ if (pose.kick) {
221
+ cell(13, 3);
222
+ cell(13, 4);
223
+ }
224
+ }
225
+
226
+ // ---- football animations for the mascot ----
227
+ export const MASCOT_ANIMS = ['kick', 'juggle', 'header', 'dribble', 'goal'];
228
+ export const ANIM_FRAMES = { kick: 16, juggle: 20, header: 16, dribble: 20, goal: 18 };
229
+ const CONFETTI = [
230
+ [106, 155, 204], // sky
231
+ [120, 140, 93], // olive
232
+ [196, 102, 134], // fig
233
+ [203, 202, 219], // heather
234
+ [217, 119, 87], // clay
235
+ ];
236
+
237
+ const lerp = (a, b, t) => Math.round(a + (b - a) * t);
238
+
239
+ /** Pick a random animation that isn't the one we just played. */
240
+ export function pickAnimation(last, rng = Math.random) {
241
+ const choices = MASCOT_ANIMS.filter((a) => a !== last);
242
+ return choices[Math.floor(rng() * choices.length)] || MASCOT_ANIMS[0];
243
+ }
244
+
245
+ /** Idle gap before the next animation: 5-15 seconds. */
246
+ export function nextAnimDelay(rng = Math.random) {
247
+ return 5000 + Math.floor(rng() * 10000);
248
+ }
249
+
250
+ /**
251
+ * Deterministic per-frame plan for an animation. Pure: only depends on the
252
+ * animation name/frame and the mascot anchor. Returns the mascot pose plus
253
+ * the ball, trail, confetti and goal flag for drawFootball to render.
254
+ * Everything stays inside the mascot's own rows (my..my+4) so the legend
255
+ * row above and the footer below are never touched.
256
+ * mx,my = mascot top-left; cols = grid width (for right-edge exits).
257
+ */
258
+ export function footballScene(anim, mx, my, cols) {
259
+ const f = anim.frame;
260
+ const out = { pose: {}, ball: null, trail: [], confetti: [], goal: false };
261
+ const right = cols - 3;
262
+
263
+ if (anim.name === 'kick') {
264
+ // ball rolls in along the ground, mascot strikes it, it rockets out right
265
+ if (f <= 7) {
266
+ out.ball = { x: lerp(right, mx + 14, f / 7), y: my + 3 };
267
+ } else if (f === 8) {
268
+ out.ball = { x: mx + 14, y: my + 3 };
269
+ out.pose = { kick: 1 };
270
+ } else {
271
+ const t = (f - 9) / 6;
272
+ const bx = lerp(mx + 15, cols + 8, t);
273
+ const by = my + 3 - Math.min(2, Math.round(t * 2)); // lofts from my+3 up to my+1
274
+ out.ball = { x: bx, y: by };
275
+ out.trail = [{ x: bx - 2, y: by + 1 }, { x: bx - 4, y: by + 1 }];
276
+ out.pose = { kick: f <= 10 ? 1 : 0 };
277
+ }
278
+ } else if (anim.name === 'juggle') {
279
+ // keepie-uppies: ball hops foot -> belly -> forehead -> belly (skips the
280
+ // eyes row and stays in the gap between the nubs, so the face stays clear)
281
+ const heights = [my + 4, my + 3, my + 1, my + 3];
282
+ const by = heights[f % 4];
283
+ out.ball = { x: mx + 6, y: by };
284
+ out.pose = { armTwitch: by <= my + 1 ? 1 : 0, legPhase: f % 2 };
285
+ } else if (anim.name === 'header') {
286
+ // ball flies in at head height, mascot leans into it, it bounces out right
287
+ if (f <= 7) {
288
+ out.ball = { x: lerp(cols + 4, mx + 7, f / 7), y: my };
289
+ out.pose = { shiftX: f >= 5 ? 1 : 0 };
290
+ } else if (f === 8) {
291
+ out.ball = { x: mx + 6, y: my };
292
+ } else {
293
+ const t = (f - 9) / 6;
294
+ const bx = lerp(mx + 7, cols + 6, t);
295
+ out.ball = { x: bx, y: my };
296
+ out.trail = [{ x: bx - 2, y: my + 1 }];
297
+ }
298
+ } else if (anim.name === 'dribble') {
299
+ // close control: ball weaves at the feet while the body sways and steps
300
+ const u = Math.sin((f / 20) * Math.PI * 4) * 0.5 + 0.5;
301
+ out.ball = { x: mx + 1 + Math.round(u * 10), y: my + 4 };
302
+ out.pose = { shiftX: Math.round(Math.sin((f / 20) * Math.PI * 2)), legPhase: Math.floor(f / 2) % 2 };
303
+ } else if (anim.name === 'goal') {
304
+ // arms up, a wiggle of joy, GOAL! and confetti raining beside the mascot
305
+ const beat = Math.floor(f / 3) % 2 === 0;
306
+ out.pose = { armsUp: true, shiftX: beat ? 0 : 1 };
307
+ out.ball = { x: mx + 6, y: my + 4 };
308
+ out.goal = beat;
309
+ const seeds = [
310
+ [15, 0],
311
+ [18, 2],
312
+ [21, 1],
313
+ [24, 4],
314
+ [17, 3],
315
+ [22, 0],
316
+ ];
317
+ out.confetti = seeds.map(([dx, dy0], i) => ({
318
+ x: mx + dx,
319
+ y: my + ((dy0 + Math.floor(f / 2)) % 5), // rows my..my+4
320
+ color: CONFETTI[(i + f) % CONFETTI.length],
321
+ }));
322
+ }
323
+ return out;
324
+ }
325
+
326
+ /** Renders the ball/trail/confetti/GOAL, clamped to the mascot's own rows. */
327
+ function drawFootball(g, mx, my, scene, cols) {
328
+ const paint = (cx, cy, ch, attr) => {
329
+ if (cy < my || cy > my + 4) return; // never touch the legend above / footer below
330
+ if (cx < mx || cx > cols - 2) return; // never touch the right border
331
+ g.put(cx, cy, ch, attr);
332
+ };
333
+ for (const c of scene.confetti) paint(c.x, c.y, '\u00b7', { fg: c.color, bold: true });
334
+ for (const t of scene.trail) paint(t.x, t.y, '\u00b7', { fg: CLOUD, dim: true });
335
+ if (scene.ball) paint(scene.ball.x, scene.ball.y, '\u25cf', { fg: IVORY, bold: true });
336
+ if (scene.goal) {
337
+ const text = 'GOAL!';
338
+ for (let i = 0; i < text.length; i++) paint(mx + 15 + i, my + 1, text[i], { fg: KRAFT, bold: true });
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Pure frame renderer (exported for tests).
344
+ * state: { cols, rows, fill, estimated, stats, usage, falling,
345
+ * bubbles, disturb, spills, frame, active, eco, url, colorMode,
346
+ * mascot } where mascot = { name, frame, frames } | null
347
+ */
348
+ export function composeFrame(state) {
349
+ const cols = Math.min(state.cols || 80, 110);
350
+ const rows = Math.min(state.rows || 24, 32);
351
+ const mode = state.colorMode || 24;
352
+ const frame = state.frame || 0;
353
+ const { fill, stats, usage } = state;
354
+ const liquid = liquidColorFor(fill);
355
+ const spark = state.active ? SPARK_FRAMES[Math.floor(frame / 2) % SPARK_FRAMES.length] : SPARK_IDLE;
356
+
357
+ // ---- tiny terminals: a single statusline-style row ----
358
+ if (cols < 46 || rows < 17) {
359
+ const slots = 10;
360
+ const filled = Math.round((fill / 100) * slots);
361
+ const barColor = fgSeq(fill >= 95 ? EMBER : CLAY, mode);
362
+ let line = `\x1b[${barColor}m${spark} ${'\u2588'.repeat(filled)}\x1b[0m\x1b[2m${'\u2591'.repeat(slots - filled)}\x1b[0m`;
363
+ line += ` \x1b[1m${Math.round(fill)}%\x1b[0m`;
364
+ if (cols >= 44) line += ` \x1b[2m\u00b7 ${fmt(stats.totalTokens)} tok\x1b[0m`;
365
+ if (cols >= 58) line += ` \x1b[2m\u00b7 ${stats.toolCalls} tools\x1b[0m`;
366
+ return line;
367
+ }
368
+
369
+ const g = new Grid(cols, rows, mode);
370
+ const bx = 2; // trophy left edge
371
+ const jarTop = 2;
372
+ const bodyH = bodyHeightFor(rows);
373
+ const H = bodyH + 5;
374
+ const slabRel = H - 4;
375
+ const floorY = jarTop + H - 1;
376
+ const rowAt = (rel) => jarTop + rel;
377
+ const ix = (i) => bx + 1 + i; // interior column -> grid x
378
+
379
+ const glass = { fg: CLOUD, dim: true };
380
+ const eyebrow = { fg: CLOUD, dim: true };
381
+ const panel = { fg: CLAY, dim: true }; // Claude Code's welcome-box border
382
+
383
+ // ---- panel: rounded border with the title in it, like Claude Code's box ----
384
+ g.put(0, 0, '\u256d', panel);
385
+ g.text(1, 0, '\u2500'.repeat(cols - 2), panel);
386
+ g.put(cols - 1, 0, '\u256e', panel);
387
+ for (let y = 1; y < rows - 1; y++) {
388
+ g.put(0, y, '\u2502', panel);
389
+ g.put(cols - 1, y, '\u2502', panel);
390
+ }
391
+ g.put(0, rows - 1, '\u2570', panel);
392
+ g.text(1, rows - 1, '\u2500'.repeat(cols - 2), panel);
393
+ g.put(cols - 1, rows - 1, '\u256f', panel);
394
+
395
+ g.text(2, 0, ` ${spark} claude cup `, { fg: CLAY, bold: true });
396
+ const now = new Date();
397
+ const dateLabel = ` ${now.toLocaleDateString('en-US', { weekday: 'long' })} \u00b7 ${now
398
+ .toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} `.toLowerCase();
399
+ g.text(cols - 3 - dateLabel.length, 0, dateLabel, eyebrow);
400
+
401
+ // ---- the golden trophy ----
402
+ const { G } = profileFor(bodyH);
403
+ const gold = { fg: KRAFT };
404
+ const goldBold = { fg: KRAFT, bold: true };
405
+ const goldDim = { fg: KRAFT, dim: true };
406
+
407
+ // globe cap arc
408
+ g.put(ix(6), rowAt(0), '\u256d', goldBold);
409
+ g.text(ix(7), rowAt(0), '\u2500'.repeat(6), goldBold);
410
+ g.put(ix(13), rowAt(0), '\u256e', goldBold);
411
+ // globe: curved sides that bulge to the equator, with meridian detail
412
+ for (let rel = 1; rel <= G - 1; rel++) {
413
+ const sp = spanFor(rel, bodyH);
414
+ const prev = rel === 1 ? { lo: 7, hi: 12 } : spanFor(rel - 1, bodyH);
415
+ const widening = sp.lo < prev.lo;
416
+ g.put(ix(sp.lo) - 1, rowAt(rel), widening ? '(' : '\\', gold);
417
+ g.put(ix(sp.hi) + 1, rowAt(rel), widening ? ')' : '/', gold);
418
+ if (sp.hi - sp.lo + 1 >= 12) {
419
+ g.put(ix(sp.lo + 2), rowAt(rel), '(', goldDim);
420
+ g.put(ix(sp.hi - 2), rowAt(rel), ')', goldDim);
421
+ }
422
+ }
423
+ // figures tapering under the globe, then the solid double-struck stem
424
+ g.put(ix(6), rowAt(G), '\\', gold);
425
+ g.put(ix(13), rowAt(G), '/', gold);
426
+ for (let rel = G + 1; rel <= H - 5; rel++) {
427
+ g.put(ix(6), rowAt(rel), '\u2551', gold);
428
+ g.put(ix(13), rowAt(rel), '\u2551', gold);
429
+ }
430
+ // pedestal step (stem stands on it)
431
+ g.put(ix(3), rowAt(slabRel), '\u256d', goldBold);
432
+ g.text(ix(4), rowAt(slabRel), '\u2500'.repeat(2), goldBold);
433
+ g.put(ix(6), rowAt(slabRel), '\u256f', goldBold);
434
+ g.put(ix(13), rowAt(slabRel), '\u2570', goldBold);
435
+ g.text(ix(14), rowAt(slabRel), '\u2500'.repeat(2), goldBold);
436
+ g.put(ix(16), rowAt(slabRel), '\u256e', goldBold);
437
+ // pedestal tiers (each wider than the one above)
438
+ g.put(ix(3), rowAt(H - 3), '\u2502', gold);
439
+ g.put(ix(16), rowAt(H - 3), '\u2502', gold);
440
+ g.put(ix(2), rowAt(H - 2), '\u2502', gold);
441
+ g.put(ix(17), rowAt(H - 2), '\u2502', gold);
442
+ g.put(ix(2), floorY, '\u2570', goldBold);
443
+ g.text(ix(3), floorY, '\u2500'.repeat(14), goldBold);
444
+ g.put(ix(17), floorY, '\u256f', goldBold);
445
+
446
+ // ---- liquid: one solid clay fill following the trophy's silhouette ----
447
+ const interior = interiorRowsFor(bodyH);
448
+ const fillRows = fillRowsFor(bodyH, fill);
449
+ const flooded = new Set(interior.slice(interior.length - fillRows));
450
+ const surfaceRel = fillRows > 0 ? interior[interior.length - fillRows] : null;
451
+ const surfaceY = surfaceRel !== null ? rowAt(surfaceRel) : floorY;
452
+ const surfaceColor = mix(liquid, IVORY, 0.55);
453
+ const floodedList = interior.slice(interior.length - fillRows);
454
+ const shadeAt = (rel) => {
455
+ const idx = Math.max(0, floodedList.indexOf(rel));
456
+ return mix(liquid, BURNT, (idx / Math.max(1, fillRows)) * 0.3);
457
+ };
458
+
459
+ for (const rel of floodedList) {
460
+ const sp = spanFor(rel, bodyH);
461
+ const shade = shadeAt(rel);
462
+ for (let i = sp.lo; i <= sp.hi; i++) {
463
+ if (rel === surfaceRel) {
464
+ const disturbed = (state.disturb?.[i] || 0) > 0;
465
+ const ch = disturbed ? '\u2248' : (i + Math.floor(frame * 0.6)) % 3 === 1 ? '\u2248' : '~';
466
+ g.put(ix(i), rowAt(rel), ch, { fg: surfaceColor, bg: liquid, bold: disturbed });
467
+ } else {
468
+ g.put(ix(i), rowAt(rel), ' ', { bg: shade });
469
+ }
470
+ }
471
+ }
472
+
473
+ // ---- bubbles (pale glints inside the fill) ----
474
+ for (const b of state.bubbles || []) {
475
+ const y = Math.round(b.y);
476
+ const rel = y - jarTop;
477
+ if (y > surfaceY && y < floorY && flooded.has(rel)) {
478
+ const sp = spanFor(rel, bodyH);
479
+ const x = Math.max(sp.lo, Math.min(sp.hi, Math.round(b.x)));
480
+ g.put(ix(x), y, '\u00b0', { fg: surfaceColor, bg: shadeAt(rel) });
481
+ }
482
+ }
483
+
484
+ // ---- falling activity: clay droplets (or gold when elevated/high_agency) ----
485
+ const GOLD = [244, 211, 94];
486
+ const dropColor = (state.powerLevel === 'high_agency' || state.powerLevel === 'elevated') ? GOLD : CLAY;
487
+ for (const f of state.falling || []) {
488
+ const gy = Math.round(jarTop + 1 + f.y);
489
+ if (gy >= floorY || gy >= surfaceY) continue;
490
+ const sp = spanFor(gy - jarTop, bodyH);
491
+ const x = Math.max(sp.lo, Math.min(sp.hi, f.fx));
492
+ g.put(ix(x), gy, '\u00b7', { fg: dropColor, bold: true });
493
+ }
494
+
495
+ // ---- splash droplets above a disturbed surface ----
496
+ if (surfaceRel !== null && surfaceRel > 1) {
497
+ const above = spanFor(surfaceRel - 1, bodyH);
498
+ for (let i = above.lo; i <= above.hi; i++) {
499
+ if ((state.disturb?.[i] || 0) > 2) g.put(ix(i), surfaceY - 1, '\u00b7', { fg: surfaceColor });
500
+ }
501
+ }
502
+
503
+ // ---- overflow droplets dripping off the globe onto the base ----
504
+ for (const s of state.spills || []) {
505
+ const y = Math.round(s.y);
506
+ const rel = y - jarTop;
507
+ if (rel < 1 || rel > H - 5) continue;
508
+ g.put(s.side < 0 ? ix(0) : ix(19), y, '\u00b7', { fg: liquid });
509
+ }
510
+
511
+ // ---- the % sits at the globe's equator ----
512
+ {
513
+ const gm = Math.round((G - 1) / 2);
514
+ const label = `${Math.round(fill)}%`;
515
+ const sp = spanFor(gm, bodyH);
516
+ const lx = ix(sp.lo + Math.floor((sp.hi - sp.lo + 1 - label.length) / 2));
517
+ const inLiquid = flooded.has(gm);
518
+ g.text(lx, rowAt(gm), label, inLiquid ? { fg: IVORY, bg: shadeAt(gm), bold: true } : { bold: true });
519
+ }
520
+
521
+ // ---- "CLAUDE CUP" engraved on the pedestal ----
522
+ {
523
+ const text = 'CLAUDE CUP';
524
+ const rel = H - 2;
525
+ const sp = spanFor(rel, bodyH);
526
+ const lx = ix(sp.lo + Math.floor((sp.hi - sp.lo + 1 - text.length) / 2));
527
+ const inLiquid = flooded.has(rel);
528
+ g.text(lx, rowAt(rel), text, inLiquid ? { fg: mix(liquid, IVORY, 0.75), bg: shadeAt(rel) } : { fg: CLOUD, dim: true });
529
+ }
530
+
531
+ // ---- right column ----
532
+ const sx = bx + INNER_W + 6;
533
+ if (cols >= sx + 26) {
534
+ const colW = Math.min(36, cols - sx - 3);
535
+ let y = jarTop;
536
+
537
+ // hero: percentage + bar + eyebrow
538
+ const heroColor = fill >= 95 ? EMBER : CLAY;
539
+ g.text(sx, y, `${Math.round(fill)}%`, { fg: heroColor, bold: true });
540
+ y++;
541
+ const barW = Math.min(24, colW - 2);
542
+ const filled = Math.round((fill / 100) * barW);
543
+ g.text(sx, y, '\u2588'.repeat(filled), { fg: heroColor });
544
+ g.text(sx + filled, y, '\u2591'.repeat(barW - filled), { fg: CLOUD, dim: true });
545
+ y++;
546
+ const fiveH = usage?.fiveHour;
547
+ const eyebrowText = state.estimated
548
+ ? 'EST. ACTIVITY (NO LIMIT DATA)'
549
+ : `OF 5H LIMIT USED${fiveH?.resetsAt ? ` \u00b7 RESETS IN ${countdown(fiveH.resetsAt).toUpperCase()}` : ''}`;
550
+ g.text(sx, y, eyebrowText.slice(0, colW), eyebrow);
551
+ y++;
552
+ if (fill >= 95) {
553
+ g.text(sx, y, '! OVERFLOWING \u2014 LIMIT NEARLY FULL'.slice(0, colW), { fg: EMBER, bold: true });
554
+ y++;
555
+ }
556
+
557
+ // hairline + stats
558
+ g.text(sx, y, '\u2500'.repeat(colW), { fg: CLOUD, dim: true });
559
+ y++;
560
+ const statRow = (label, value, vAttr = { bold: true }) => {
561
+ if (y >= rows - 2) return;
562
+ g.text(sx, y, label.padEnd(15), eyebrow);
563
+ g.text(sx + 15, y, String(value).slice(0, colW - 15), vAttr);
564
+ y++;
565
+ };
566
+ statRow('TOKENS TODAY', fmt(stats.totalTokens));
567
+ const eff = stats.assistantMessages > 0 ? `${fmt(Math.round(stats.totalTokens / stats.assistantMessages))} tok/reply` : '\u2014';
568
+ statRow('EFFICIENCY', eff);
569
+ statRow('REPLIES', fmt(stats.assistantMessages));
570
+ statRow('EST. COST', `$${(stats.cost ?? 0).toFixed(2)}`);
571
+ statRow('BURN RATE', `${fmt(stats.burnRate)} tok/min`);
572
+ if (usage?.fiveHour) {
573
+ const tl = usage.timeLeft;
574
+ if (!tl) statRow('TIME LEFT', 'measuring pace...', { fg: CLOUD, dim: true });
575
+ else if (tl.outlasts) statRow('TIME LEFT', 'lasts past reset', { bold: true });
576
+ else {
577
+ const h = Math.floor(tl.minutes / 60);
578
+ const m = Math.round(tl.minutes % 60);
579
+ const dur = h > 0 ? `${h}h ${m}m` : `${m}m`;
580
+ statRow('TIME LEFT', `~${dur} at this pace`, tl.minutes < 30 ? { fg: EMBER, bold: true } : { bold: true });
581
+ }
582
+ }
583
+ if (usage?.sevenDay) {
584
+ const sd = usage.sevenDay;
585
+ const mini = 8;
586
+ const mf = Math.round((sd.pct / 100) * mini);
587
+ if (y < rows - 2) {
588
+ g.text(sx, y, 'WEEKLY'.padEnd(15), eyebrow);
589
+ g.text(sx + 15, y, '\u2588'.repeat(mf), { fg: sd.pct > 85 ? EMBER : KRAFT });
590
+ g.text(sx + 15 + mf, y, '\u2591'.repeat(mini - mf), { fg: CLOUD, dim: true });
591
+ g.text(sx + 15 + mini + 1, y, `${Math.round(sd.pct)}%`, { bold: true });
592
+ const cd = countdown(sd.resetsAt);
593
+ if (cd) g.text(sx + 15 + mini + 1 + `${Math.round(sd.pct)}%`.length + 1, y, `\u00b7 \u21bb ${cd}`, eyebrow);
594
+ y++;
595
+ }
596
+ }
597
+ // power level badge (from the calibrator) — only shown when explicitly set
598
+ if (y < rows - 2 && state.powerLevel) {
599
+ const pl = state.powerLevel;
600
+ const GOLD_BADGE = [244, 211, 94];
601
+ g.text(sx, y, 'POWER'.padEnd(15), eyebrow);
602
+ if (pl === 'high_agency') {
603
+ g.text(sx + 15, y, '\u2605 HIGH-AGENCY', { fg: GOLD_BADGE, bold: true });
604
+ } else if (pl === 'elevated') {
605
+ g.text(sx + 15, y, '\u25b2 ELEVATED', { fg: KRAFT, bold: true });
606
+ } else {
607
+ g.text(sx + 15, y, '\u00b7 standard', { fg: CLOUD, dim: true });
608
+ }
609
+ y++;
610
+ }
611
+ if (y < rows - 2) {
612
+ const OLIVE = TOKEN_COLORS.edit;
613
+ g.text(sx, y, 'ECO MODE'.padEnd(15), eyebrow);
614
+ if (state.eco) {
615
+ g.put(sx + 15, y, '\u25c9', { fg: OLIVE });
616
+ g.text(sx + 17, y, 'ON \u00b7 token saver', { fg: OLIVE, bold: true });
617
+ } else {
618
+ g.put(sx + 15, y, '\u25cb', { fg: CLOUD, dim: true });
619
+ g.text(sx + 17, y, 'OFF', { fg: CLOUD, dim: true });
620
+ }
621
+ y++;
622
+ }
623
+
624
+ // category legend
625
+ if (y < rows - 2) {
626
+ g.text(sx, y, '\u2500'.repeat(colW), { fg: CLOUD, dim: true });
627
+ y++;
628
+ const cats = [
629
+ ['read', 'read', stats.toolsByCategory?.read || 0],
630
+ ['edit', 'edit', stats.toolsByCategory?.edit || 0],
631
+ ['terminal', 'term', stats.toolsByCategory?.terminal || 0],
632
+ ['web', 'web', stats.toolsByCategory?.web || 0],
633
+ ['agent', 'agent', stats.toolsByCategory?.agent || 0],
634
+ ['ai', 'replies', stats.assistantMessages || 0],
635
+ ];
636
+ let cx = sx;
637
+ for (const [key, lbl, n] of cats) {
638
+ const seg = `${lbl} ${n}`;
639
+ if (cx + seg.length + 2 > sx + colW) {
640
+ cx = sx;
641
+ y++;
642
+ if (y >= rows - 2) break;
643
+ }
644
+ g.put(cx, y, '\u25cf', { fg: TOKEN_COLORS[key] });
645
+ g.text(cx + 2, y, seg, { fg: CLOUD });
646
+ cx += seg.length + 5;
647
+ }
648
+ }
649
+
650
+ // Clawd keeps you company when there's room - and plays a little football
651
+ y += 1;
652
+ if (y + 4 <= rows - 3) {
653
+ const scene = state.mascot ? footballScene(state.mascot, sx, y, cols) : null;
654
+ drawMascot(g, sx, y, scene ? scene.pose : {});
655
+ if (scene) drawFootball(g, sx, y, scene, cols);
656
+ }
657
+ }
658
+
659
+ // ---- footer ----
660
+ const shortUrl = String(state.url || '').replace(/^https?:\/\//, '');
661
+ g.text(bx, rows - 2, `(e: eco mode \u00b7 ctrl+c to quit \u00b7 web ui at ${shortUrl})`.slice(0, cols - bx - 2), eyebrow);
662
+
663
+ return g.toString();
664
+ }
665
+
666
+ // startTui uses surfaceRelFor + bodyHeightFor for the droplet/bubble physics.
667
+ export function startTui({ aggregator, poller, watcher, eco, url, getPower, out = process.stdout }) {
668
+ let colorMode = 24;
669
+ try {
670
+ if (out.hasColors && !out.hasColors(16777216)) colorMode = 256;
671
+ } catch {
672
+ /* keep truecolor */
673
+ }
674
+
675
+ const falling = [];
676
+ const bubbles = [];
677
+ const spills = [];
678
+ const disturb = new Array(INNER_W).fill(0);
679
+ let frame = 0;
680
+ let lastActivity = 0;
681
+ let ecoOn = eco ? eco.status().on : false; // real eco mode, toggled with "e"
682
+
683
+ // football: idle for a 5-15s gap, then play one ~2s animation
684
+ let mascotAnim = null; // { name, frame, frames } | null
685
+ let lastAnim = null;
686
+ let nextAnimAt = Date.now() + nextAnimDelay();
687
+
688
+ const spawn = () => {
689
+ if (falling.length > 26) return;
690
+ falling.push({
691
+ fx: 1 + Math.floor(Math.random() * (INNER_W - 2)),
692
+ y: 0,
693
+ vy: 0.6 + Math.random() * 0.2,
694
+ });
695
+ lastActivity = Date.now();
696
+ };
697
+
698
+ const onEvent = (evt, { live }) => {
699
+ if (!live || evt.kind !== 'assistant') return;
700
+ spawn();
701
+ for (let i = 0; i < evt.tools.length; i++) spawn();
702
+ };
703
+ watcher.on('event', onEvent);
704
+
705
+ // seed a gentle drizzle of today's earlier activity
706
+ const snap = aggregator.snapshot();
707
+ const seedCount = Math.min(24, snap.toolCalls + snap.assistantMessages);
708
+ for (let i = 0; i < seedCount; i++) setTimeout(() => spawn(), 300 + i * 170);
709
+
710
+ out.write('\x1b[?1049h\x1b[?25l'); // alt screen, hide cursor
711
+
712
+ const render = () => {
713
+ frame++;
714
+ const stats = aggregator.snapshot();
715
+ const usage = poller.state;
716
+ const hasLimit = usage && usage.fiveHour && (usage.status === 'ok' || usage.status === 'stale');
717
+ const fill = hasLimit
718
+ ? usage.fiveHour.pct
719
+ : Math.min(92, Math.max(stats.totalTokens > 0 ? 4 : 0, (stats.totalTokens / 150000) * 100));
720
+
721
+ const rows = Math.min(out.rows || 24, 32);
722
+ const bodyH = bodyHeightFor(rows);
723
+ const H = bodyH + 5;
724
+ const surfaceRel = surfaceRelFor(bodyH, fill); // top flooded row, or null
725
+ const restRel = surfaceRel !== null ? surfaceRel : H - 2; // land on liquid or base floor
726
+
727
+ // falling droplets: accelerate, then merge into the liquid with a splash
728
+ for (let i = falling.length - 1; i >= 0; i--) {
729
+ const f = falling[i];
730
+ f.vy = Math.min(1.6, f.vy + 0.12);
731
+ f.y += f.vy;
732
+ if (f.y >= restRel - 0.2) {
733
+ if (surfaceRel !== null) {
734
+ disturb[f.fx] = 4;
735
+ bubbles.push({ x: f.fx, y: 0, born: frame });
736
+ }
737
+ falling.splice(i, 1);
738
+ }
739
+ }
740
+
741
+ // bubbles rise from the base toward the surface
742
+ const fillRows = surfaceRel !== null ? H - 2 - surfaceRel : 0;
743
+ if (fillRows >= 3 && Math.random() < 0.12 + (falling.length ? 0.1 : 0)) {
744
+ bubbles.push({ x: 1 + Math.random() * (INNER_W - 2), y: 0, born: frame });
745
+ }
746
+ for (let i = bubbles.length - 1; i >= 0; i--) {
747
+ const b = bubbles[i];
748
+ b.y = b.y || H - 2.5;
749
+ b.y -= 0.3;
750
+ b.x += Math.sin((frame + i) * 0.4) * 0.15;
751
+ if (surfaceRel === null || b.y <= surfaceRel + 0.5) bubbles.splice(i, 1);
752
+ }
753
+ // bubble rows are jar-relative; compose expects grid rows (jarTop = 2)
754
+ const bubblesGrid = bubbles.map((b) => ({ x: Math.max(0, Math.min(INNER_W - 1, b.x)), y: 2 + b.y }));
755
+
756
+ // overflow: droplets drip off the globe, down past the stem, onto the base
757
+ if (fill >= 95 && Math.random() < 0.25 && spills.length < 8) {
758
+ spills.push({ side: Math.random() < 0.5 ? -1 : 1, y: 1 });
759
+ }
760
+ for (let i = spills.length - 1; i >= 0; i--) {
761
+ spills[i].y += 0.7;
762
+ if (spills[i].y > H - 5) spills.splice(i, 1);
763
+ }
764
+
765
+ for (let i = 0; i < INNER_W; i++) if (disturb[i] > 0) disturb[i]--;
766
+
767
+ // football scheduler: start a new animation once the idle gap elapses
768
+ if (!mascotAnim && Date.now() >= nextAnimAt) {
769
+ const name = pickAnimation(lastAnim);
770
+ mascotAnim = { name, frame: 0, frames: ANIM_FRAMES[name] };
771
+ }
772
+
773
+ const power = typeof getPower === 'function' ? getPower() : { powerLevel: 'standard', richness: 0 };
774
+ const frameStr = composeFrame({
775
+ cols: out.columns || 80,
776
+ rows,
777
+ fill,
778
+ estimated: !hasLimit,
779
+ stats,
780
+ usage,
781
+ falling,
782
+ bubbles: bubblesGrid,
783
+ disturb,
784
+ spills,
785
+ frame,
786
+ active: falling.length > 0 || Date.now() - lastActivity < 3000,
787
+ eco: ecoOn,
788
+ url,
789
+ colorMode,
790
+ mascot: mascotAnim,
791
+ powerLevel: power.powerLevel,
792
+ richness: power.richness,
793
+ });
794
+ out.write('\x1b[H' + frameStr + '\x1b[J');
795
+
796
+ // advance the animation for the next tick; reschedule when it finishes
797
+ if (mascotAnim) {
798
+ mascotAnim.frame++;
799
+ if (mascotAnim.frame >= mascotAnim.frames) {
800
+ lastAnim = mascotAnim.name;
801
+ mascotAnim = null;
802
+ nextAnimAt = Date.now() + nextAnimDelay();
803
+ }
804
+ }
805
+ };
806
+
807
+ const timer = setInterval(render, 140);
808
+ render();
809
+
810
+ // keyboard: "e" toggles eco mode for real; raw mode swallows ctrl+c, so re-emit it
811
+ const onKey = (buf) => {
812
+ const k = buf.toString();
813
+ if (k === '\u0003') {
814
+ process.emit('SIGINT');
815
+ return;
816
+ }
817
+ if (k === 'e' || k === 'E') {
818
+ if (eco) {
819
+ const r = ecoOn ? eco.disable() : eco.enable();
820
+ if (r.ok) ecoOn = r.status.on;
821
+ } else {
822
+ ecoOn = !ecoOn;
823
+ }
824
+ render();
825
+ }
826
+ };
827
+ const keysActive = process.stdin.isTTY === true;
828
+ if (keysActive) {
829
+ process.stdin.setRawMode?.(true);
830
+ process.stdin.resume();
831
+ process.stdin.on('data', onKey);
832
+ }
833
+
834
+ const stop = () => {
835
+ clearInterval(timer);
836
+ watcher.off('event', onEvent);
837
+ if (keysActive) {
838
+ process.stdin.off('data', onKey);
839
+ process.stdin.setRawMode?.(false);
840
+ process.stdin.pause();
841
+ }
842
+ out.write('\x1b[?25h\x1b[?1049l'); // restore cursor, leave alt screen
843
+ };
844
+ return stop;
845
+ }