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/LICENSE +21 -0
- package/MANUAL-SETUP.md +53 -0
- package/README.md +144 -0
- package/WHITE_HAT_RESEARCH.md +254 -0
- package/dist/web/app.js +32 -0
- package/dist/web/index.html +127 -0
- package/dist/web/styles.css +400 -0
- package/docs/screenshot.png +0 -0
- package/docs/tui-trophy.png +0 -0
- package/docs/web-trophy-canvas.png +0 -0
- package/mcp-server/dist/mcp-server.mjs +16 -0
- package/mcp-server/package.json +15 -0
- package/mcp-server/src/calibrator.js +138 -0
- package/mcp-server/src/db.js +272 -0
- package/mcp-server/src/environment-richness.js +83 -0
- package/mcp-server/src/fingerprint.js +79 -0
- package/mcp-server/src/harvest.js +496 -0
- package/mcp-server/src/hook-ingest.js +153 -0
- package/mcp-server/src/index.js +181 -0
- package/mcp-server/src/intensity.js +77 -0
- package/mcp-server/src/registration.js +184 -0
- package/mcp-server/src/uploader.js +64 -0
- package/package.json +59 -0
- package/scripts/add-log-safety-check.mjs +43 -0
- package/scripts/build-mcp-launcher.mjs +40 -0
- package/shared/types.js +84 -0
- package/src/aggregator.js +263 -0
- package/src/cli.js +300 -0
- package/src/eco.js +151 -0
- package/src/parse.js +86 -0
- package/src/server.js +162 -0
- package/src/statusline.js +71 -0
- package/src/tui.js +845 -0
- package/src/usage-api.js +250 -0
- package/src/watcher.js +104 -0
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
|
+
}
|