dankgrinder 7.77.0 → 7.79.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/lib/grinder.js CHANGED
@@ -3,6 +3,7 @@ const Redis = require('ioredis');
3
3
  const commands = require('./commands');
4
4
  const { setDashboardActive, isCV2, ensureCV2, stripAnsi } = require('./commands/utils');
5
5
  const rawLogger = require('./rawLogger');
6
+ const terminal = require('./terminal');
6
7
  const {
7
8
  BloomFilter, RingBuffer, TokenBucket, EMA, SlidingWindowCounter,
8
9
  AhoCorasick, LRUCache, StringPool, AsyncBatchQueue, JitterBackoff,
@@ -338,6 +339,17 @@ function colorBanner() {
338
339
 
339
340
  // ── Simple Logging ─────────────────────────────────────────────
340
341
  function log(type, msg, label) {
342
+ // Route grinding logs through terminal flash events when active
343
+ if (terminal._active) {
344
+ const clean = stripAnsi(String(msg || '')).substring(0, 120);
345
+ const tagClean = stripAnsi(String(label || ''));
346
+ terminal.flashEvent(
347
+ type === 'error' ? 'death' : type === 'warn' ? 'warn' : 'info',
348
+ `${tagClean ? tagClean + ' ' : ''}${clean}`
349
+ );
350
+ return;
351
+ }
352
+
341
353
  const colorIcons = {
342
354
  info: `${c.dim}·${c.reset}`, success: `${rgb(52, 211, 153)}✓${c.reset}`,
343
355
  error: `${rgb(239, 68, 68)}✗${c.reset}`, warn: `${rgb(251, 191, 36)}!${c.reset}`,
@@ -1623,6 +1635,8 @@ class AccountWorker {
1623
1635
  // ── Death / lifesaver detection in command responses ──
1624
1636
  if (resultLower.includes('you died') || resultLower.includes('lifesaver protected')) {
1625
1637
  this.log('error', `DEATH DETECTED during ${cmdName}!`);
1638
+ terminal.flashEvent('death', `💀 ${this.username} died — ${lsCount === 0 ? '0 lifesavers!' : (lsCount > 0 ? `${lsCount} lifesavers left` : 'check DM')}`);
1639
+ terminal.markWorkerDirty(this.idx);
1626
1640
  // Check for lifesaver count in the response
1627
1641
  const lsMatch = result.match(/(\d+)\s*life\s*saver/i);
1628
1642
  const lsCount = lsMatch ? parseInt(lsMatch[1]) : -1;
@@ -1639,6 +1653,8 @@ class AccountWorker {
1639
1653
  await redis.set(`dkg:lifesavers:${this.account.id}`, String(lsCount), 'EX', 86400);
1640
1654
  this.log('warn', `Lifesaver used! ${lsCount} remaining.`);
1641
1655
  if (lsCount <= 2) {
1656
+ terminal.flashEvent('lowls', `⚠ ${this.username} low lifesavers: ${lsCount} left`);
1657
+ terminal.markWorkerDirty(this.idx);
1642
1658
  sendWebhook('LOW LIFESAVERS', `**${this.username}** has only **${lsCount}** lifesaver(s) left!`, 0xfbbf24);
1643
1659
  }
1644
1660
  }
@@ -1758,6 +1774,8 @@ class AccountWorker {
1758
1774
  this.setStatus(formattedResult);
1759
1775
  await sendLog(this.username, cmdName, result, 'success');
1760
1776
  reportEarnings(this.account.id, this.username, earned, spent, cmdName);
1777
+ terminal.markWorkerDirty(this.idx);
1778
+ if (earned > 0) terminal.flashEvent('success', `⚔ ${this.username} ${cmdName}: +⏣ ${earned.toLocaleString()}`);
1761
1779
 
1762
1780
  // Auto-sell fish every 5 fishing rounds
1763
1781
  if (cmdName === 'fish') {
@@ -2844,6 +2862,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2844
2862
  w.log?.('error', `DEATH in DMs! 0 lifesavers — disabling crime/search`);
2845
2863
  w.setCooldown?.('crime', 86400);
2846
2864
  w.setCooldown?.('search', 86400);
2865
+ terminal.flashEvent('death', `💀 ${w.username} DEATH in DM — 0 lifesavers!`);
2866
+ terminal.markWorkerDirty(w.idx);
2847
2867
  sendWebhook?.('DEATH ALERT (DM)', `**${w.username}** died in DMs! **0 lifesavers!**\nCrime/search auto-disabled.`, 0xef4444);
2848
2868
  } else {
2849
2869
  w.log?.('warn', `DEATH in DMs — ${event.lifesaversLeft} lifesavers remaining`);
@@ -2861,6 +2881,8 @@ async function start(apiKey, apiUrl, opts = {}) {
2861
2881
  if (event.type === 'levelup') {
2862
2882
  if (event.to > 0) {
2863
2883
  w._level = event.to;
2884
+ terminal.flashEvent('levelup', `⬆️ ${w.username} leveled up to Lv.${event.to}`);
2885
+ terminal.markWorkerDirty(w.idx);
2864
2886
  }
2865
2887
  }
2866
2888
  }
@@ -2876,8 +2898,12 @@ async function start(apiKey, apiUrl, opts = {}) {
2876
2898
  console.log(` ${checks.join(' ')}`);
2877
2899
  console.log('');
2878
2900
 
2901
+ // ── Terminal renderer init ─────────────────────────────────────
2902
+ terminal.setVersion(PKG_VERSION);
2903
+ terminal.init({ workers, startTime });
2904
+ terminal.startPhase(`Connecting ${accounts.length} accounts to Discord`);
2905
+
2879
2906
  // ── Phase 1: Login accounts ─────────────────────────────────────────
2880
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Logging in ${accounts.length} account(s)...`);
2881
2907
  const parsedGapMin = Number.parseInt(String(process.env.LOGIN_GAP_MIN_MS || '50'), 10);
2882
2908
  const parsedGapMax = Number.parseInt(String(process.env.LOGIN_GAP_MAX_MS || '150'), 10);
2883
2909
  const LOGIN_GAP_MIN_MS = Number.isFinite(parsedGapMin) && parsedGapMin >= 0 ? parsedGapMin : 50;
@@ -2894,6 +2920,7 @@ async function start(apiKey, apiUrl, opts = {}) {
2894
2920
  workers.push(worker);
2895
2921
  workerMap.set(acc.id, worker);
2896
2922
  await worker.start();
2923
+ terminal.updateProgress(workers.length, accounts.length);
2897
2924
  }));
2898
2925
  if (i + BATCH_SIZE < accounts.length) await new Promise(r => setTimeout(r, randomLoginGap()));
2899
2926
  hintGC();
@@ -2902,8 +2929,9 @@ async function start(apiKey, apiUrl, opts = {}) {
2902
2929
  const loginDone = workers.filter(w => !w._tokenInvalid && w.channel).length;
2903
2930
  const invalidWorkers = workers.filter(w => w._tokenInvalid);
2904
2931
  const timedOutWorkers = workers.filter(w => !w.channel && !w._tokenInvalid);
2905
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} ${loginDone}/${accounts.length} accounts connected`);
2932
+ terminal.endPhase(`${loginDone}/${accounts.length} accounts connected`, true);
2906
2933
  if (invalidWorkers.length > 0) {
2934
+ terminal.flashEvent('error', `${invalidWorkers.length} accounts have INVALID tokens`);
2907
2935
  log('warn', `${rgb(239, 68, 68)}${invalidWorkers.length} account(s) have INVALID tokens`);
2908
2936
  for (const w of invalidWorkers) log('error', ` ${w.account.label || w.account.id} — token invalid or expired`);
2909
2937
  }
@@ -2912,24 +2940,31 @@ async function start(apiKey, apiUrl, opts = {}) {
2912
2940
  const activeWorkers = workers.filter(w => !w._tokenInvalid);
2913
2941
 
2914
2942
  // ── Phase 2: Inventory check ─────────────────────────────────────
2915
- console.log(` ${rgb(34, 211, 238)}·${c.reset} Checking inventory (${activeWorkers.length} accounts)...`);
2943
+ terminal.startPhase('Checking inventory');
2944
+ terminal.updateProgress(0, activeWorkers.length);
2916
2945
  let invDone = 0, invFailed = 0;
2917
2946
  await Promise.all(activeWorkers.map(async (w) => {
2918
2947
  try { await w.checkInventory({ force: true, requireComplete: true, maxAttempts: 3, silent: true }); }
2919
2948
  catch { invFailed++; return; }
2920
2949
  invDone++;
2950
+ terminal.updateProgress(invDone, activeWorkers.length);
2921
2951
  }));
2922
2952
 
2923
2953
  if (invFailed > 0) {
2954
+ terminal.endPhase(`Inventory: ${invFailed} failed`, false);
2924
2955
  console.log(` ${rgb(239, 68, 68)}✗${c.reset} Inventory: ${invFailed} failed — not starting grind loops`);
2925
2956
  return;
2926
2957
  }
2927
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Inventory: ${invDone}/${activeWorkers.length} clear`);
2958
+ terminal.endPhase(`Inventory: ${invDone} clear`);
2928
2959
 
2929
2960
  // ── Phase 2.5: Balance check ────────────────────────────────────
2930
- console.log(` ${rgb(251, 191, 36)}·${c.reset} Checking balance (${activeWorkers.length} accounts)...`);
2961
+ terminal.startPhase('Checking balance');
2962
+ terminal.updateProgress(0, activeWorkers.length);
2963
+ let balDone = 0;
2931
2964
  await Promise.all(activeWorkers.map(async (w) => {
2932
2965
  try { await w.checkBalance(true); } catch {}
2966
+ balDone++;
2967
+ terminal.updateProgress(balDone, activeWorkers.length);
2933
2968
  }));
2934
2969
 
2935
2970
  let totalWallet = 0, totalBank = 0, noLifesaverAccounts = [];
@@ -2938,13 +2973,16 @@ async function start(apiKey, apiUrl, opts = {}) {
2938
2973
  totalBank += w.stats?.bankBalance || 0;
2939
2974
  if (w._lifesavers === 0) noLifesaverAccounts.push(w.username || w.account.label);
2940
2975
  }
2941
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} Balance: ${c.green}⏣ ${(totalWallet + totalBank).toLocaleString()}${c.reset} ${c.dim}(wallet: ⏣ ${totalWallet.toLocaleString()} + bank: ⏣ ${totalBank.toLocaleString()})${c.reset}`);
2976
+ terminal.endPhase(`Balance: ⏣ ${(totalWallet + totalBank).toLocaleString()}`);
2942
2977
  if (noLifesaverAccounts.length > 0) {
2978
+ terminal.flashEvent('warn', `${noLifesaverAccounts.length} accounts have 0 LIFESAVERS`);
2943
2979
  console.log(` ${rgb(239, 68, 68)}⚠${c.reset} ${noLifesaverAccounts.length} account(s) have 0 LIFESAVERS — crime/search disabled`);
2944
2980
  }
2945
2981
 
2946
2982
  // ── Phase 2.75: DM history check ────────────────────────────────
2947
- console.log(` ${rgb(139, 92, 246)}·${c.reset} Checking DM history...`);
2983
+ terminal.startPhase('Checking DM history');
2984
+ terminal.updateProgress(0, activeWorkers.length);
2985
+ let dmDone = 0;
2948
2986
  let dmDeaths = 0, dmLevelUps = 0, dmNoLs = [], dmUnknown = [];
2949
2987
  for (const w of activeWorkers) {
2950
2988
  try {
@@ -2956,8 +2994,11 @@ async function start(apiKey, apiUrl, opts = {}) {
2956
2994
  if (dm.currentLevel > 0) w._level = dm.currentLevel;
2957
2995
  if (dm.lifesavers >= 0) w._lifesavers = dm.lifesavers;
2958
2996
  } catch {}
2997
+ dmDone++;
2998
+ terminal.updateProgress(dmDone, activeWorkers.length);
2959
2999
  }
2960
3000
  if (dmNoLs.length > 0) {
3001
+ terminal.flashEvent('warn', `No lifesavers: ${dmNoLs.join(', ')}`);
2961
3002
  log('warn', `⚠ No lifesavers: ${dmNoLs.join(', ')}`);
2962
3003
  for (const w of activeWorkers) {
2963
3004
  if (dmNoLs.includes(w.username) && redis) {
@@ -2975,17 +3016,16 @@ async function start(apiKey, apiUrl, opts = {}) {
2975
3016
  if (dmDeaths > 0) dmSummaryParts.push(`${dmDeaths} deaths`);
2976
3017
  if (dmLevelUps > 0) dmSummaryParts.push(`${dmLevelUps} level-ups`);
2977
3018
  if (dmUnknown.length > 0) dmSummaryParts.push(`${dmUnknown.length} pending`);
2978
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
3019
+ terminal.endPhase(`DM check: ${dmSummaryParts.length > 0 ? dmSummaryParts.join(', ') : 'clean'}`);
2979
3020
 
2980
3021
  // ── Phase 3: Start grind loops ───────────────────────────────────
2981
- console.log(` ${rgb(139, 92, 246)}>>>${c.reset} Starting grind loops...`);
2982
- // Phase 3: Start all grind loops (only for valid workers)
3022
+ terminal.startPhase(`🚀 Launching ${activeWorkers.length} grinders!`);
2983
3023
  for (const w of activeWorkers) {
2984
3024
  if (!shutdownCalled) w.grindLoop();
2985
3025
  }
2986
-
2987
- console.log(` ${rgb(52, 211, 153)}✓${c.reset} All grind loops started — ${activeWorkers.length} accounts active`);
2988
- console.log(` v${PKG_VERSION} | press Ctrl+C to stop`);
3026
+ terminal.endPhase(`${activeWorkers.length} grinders active`);
3027
+ terminal.setWorkers(workers);
3028
+ terminal.setActive();
2989
3029
 
2990
3030
  // Cluster heartbeat — lets other nodes see this node is alive
2991
3031
  if (CLUSTER_ENABLED) {
@@ -3052,35 +3092,26 @@ async function start(apiKey, apiUrl, opts = {}) {
3052
3092
  sigintHandled = true;
3053
3093
  shutdownCalled = true;
3054
3094
  setDashboardActive(false);
3055
- process.stdout.write('\x1b[?25h'); // show cursor
3056
-
3057
- const tw = process.stdout.columns || 80;
3058
- const sepBar = rgb(139, 92, 246) + c.bold + '─'.repeat(tw - 4) + c.reset;
3059
- console.log('');
3060
- console.log(` ${rgb(251, 191, 36)}${c.bold}Session Summary${c.reset}`);
3061
- console.log(sepBar);
3062
3095
 
3063
- // Collect stats from all workers (including rotated-out ones)
3064
- let finalCoins = 0;
3065
- let finalCmds = 0;
3096
+ // Collect stats for summary
3097
+ let finalCoins = 0, finalCmds = 0, totalSuccess = 0;
3066
3098
  for (const wk of workers) {
3067
- const rate = wk.stats.commands > 0 ? ((wk.stats.successes / wk.stats.commands) * 100).toFixed(0) : 0;
3068
- console.log(
3069
- ` ${wk.color}${c.bold}${(wk.username || '?').padEnd(18)}${c.reset}` +
3070
- ` ${rgb(52, 211, 153)}+⏣ ${wk.stats.coins.toLocaleString().padStart(8)}${c.reset}` +
3071
- ` ${c.dim}${wk.stats.commands.toString().padStart(4)} cmds${c.reset}` +
3072
- ` ${c.dim}${rate}% success${c.reset}`
3073
- );
3074
3099
  finalCoins += wk.stats.coins || 0;
3075
3100
  finalCmds += wk.stats.commands || 0;
3101
+ totalSuccess += wk.stats.successes || 0;
3076
3102
  }
3077
- console.log(sepBar);
3078
-
3079
3103
  const memFinal = Math.round((process.memoryUsage?.rss?.() ?? process.memoryUsage().rss) / 1048576);
3080
- const avgEarn = globalEarningsEMA.get();
3081
- const cpm = globalCmdRate.getRate().toFixed(1);
3082
- console.log(` ${rgb(251, 191, 36)}${c.bold}Total: +⏣ ${finalCoins.toLocaleString()}${c.reset} ${c.dim}in ${formatUptime()} | ${finalCmds} cmds | ~${cpm} cmd/m | ${memFinal}MB | avg earn ⏣ ${Math.round(avgEarn)}${c.reset}`);
3083
- console.log('');
3104
+ const uptimeMs = Date.now() - startTime;
3105
+
3106
+ // Show beautiful session summary via terminal renderer
3107
+ terminal.shutdown({
3108
+ totalCoins: finalCoins,
3109
+ totalCmds: finalCmds,
3110
+ totalSuccess: finalCmds > 0 ? ((totalSuccess / finalCmds) * 100).toFixed(0) : 0,
3111
+ workers,
3112
+ uptime: uptimeMs,
3113
+ memMB: memFinal,
3114
+ });
3084
3115
 
3085
3116
  // Release all cluster claims before stopping workers
3086
3117
  const releasePromises = workers.map(wk => releaseClaim(wk.account.id).catch(() => {}));
@@ -3096,22 +3127,14 @@ async function start(apiKey, apiUrl, opts = {}) {
3096
3127
 
3097
3128
  const totalRecoveries = workers.reduce((sum, wk) => sum + (wk._totalRecoveries || 0), 0);
3098
3129
  const totalDisconnects = workers.reduce((sum, wk) => sum + (wk._disconnectCount || 0), 0);
3099
- const totalRateLimits = workers.reduce((sum, wk) => sum + (wk._rateLimitHits || 0), 0);
3100
3130
 
3101
3131
  const webhookMsg = `+⏣ ${finalCoins.toLocaleString()} | ${finalCmds} cmds | ${formatUptime()}` +
3102
3132
  (totalRecoveries > 0 ? ` | ${totalRecoveries} auto-recoveries` : '') +
3103
3133
  (CLUSTER_ENABLED ? ` | node: ${NODE_ID.substring(0, 12)}` : '');
3104
3134
  sendWebhook('Session Ended', webhookMsg, 0x8b5cf6);
3105
3135
 
3106
- if (totalRecoveries > 0 || totalDisconnects > 0) {
3107
- console.log(` ${c.dim}Recovery stats: ${totalRecoveries} auto-recoveries, ${totalDisconnects} disconnects, ${totalRateLimits} rate limits${c.reset}`);
3108
- }
3109
- if (CLUSTER_ENABLED) {
3110
- console.log(` ${c.dim}Cluster node: ${NODE_ID} — claims released${c.reset}`);
3111
- }
3112
-
3113
3136
  if (redis) { try { redis.disconnect(); } catch {} }
3114
- setTimeout(() => process.exit(0), 1500);
3137
+ setTimeout(() => process.exit(0), 500);
3115
3138
  });
3116
3139
  }
3117
3140
 
@@ -0,0 +1,739 @@
1
+ /**
2
+ * terminal.js — Modern animated terminal renderer for DankGrinder
3
+ *
4
+ * Key design:
5
+ * - stdout capture during startup → buffer prevents bleed-through
6
+ * - When setActive() called: clear screen + replay buffer, then normal
7
+ * - After activation: all w.log() routed through flashEvent()
8
+ * - Virtual window: single-line per account (scales to 10k+)
9
+ * - 4 FPS render loop with dirty-row tracking (no flicker)
10
+ * - Graceful degradation: falls back to plain console.log if not TTY
11
+ */
12
+
13
+ const READY = (() => {
14
+ try {
15
+ return process.stdout.isTTY && !process.env.NO_TERM;
16
+ } catch (_) { return false; }
17
+ })();
18
+
19
+ // ── ANSI Helpers ────────────────────────────────────────────────────────────
20
+
21
+ const A = {
22
+ reset: '\x1b[0m',
23
+ bold: '\x1b[1m',
24
+ dim: '\x1b[2m',
25
+ italic: '\x1b[3m',
26
+
27
+ black: '\x1b[30m', red: '\x1b[31m', green: '\x1b[32m',
28
+ yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m',
29
+ cyan: '\x1b[36m', white: '\x1b[37m',
30
+
31
+ bgBlack: '\x1b[40m', bgRed: '\x1b[41m', bgGreen: '\x1b[42m',
32
+ bgYellow: '\x1b[43m', bgBlue: '\x1b[44m', bgMagenta: '\x1b[45m',
33
+ bgCyan: '\x1b[46m', bgWhite: '\x1b[47m',
34
+
35
+ // True-color RGB
36
+ rgb: (r, g, b) => `\x1b[38;2;${r};${g};${b}m`,
37
+
38
+ // Cursor
39
+ save: '\x1b7', restore: '\x1b8',
40
+ hide: '\x1b[?25l', show: '\x1b[?25h',
41
+ up: (n = 1) => `\x1b[${n}A`,
42
+ down: (n = 1) => `\x1b[${n}B`,
43
+ clear: '\x1b[2J',
44
+ clearLine: '\x1b[2K',
45
+ home: '\x1b[H',
46
+
47
+ // Erase in display (clears scrollback too)
48
+ eraseAll: '\x1b[3J\x1b[2J\x1b[H',
49
+
50
+ // 256-color shortcuts
51
+ purple: '\x1b[38;5;141m',
52
+ pink: '\x1b[38;5;205m',
53
+ orange: '\x1b[38;5;214m',
54
+ teal: '\x1b[38;5;44m',
55
+ lime: '\x1b[38;5;82m',
56
+ crimson: '\x1b[38;5;196m',
57
+ slate: '\x1b[38;5;245m',
58
+ gold: '\x1b[38;5;220m',
59
+ emerald: '\x1b[38;5;48m',
60
+ };
61
+
62
+ // ── Color scheme ────────────────────────────────────────────────────────────
63
+
64
+ const C = {
65
+ header: A.purple,
66
+ headerDim: A.rgb(100, 70, 180),
67
+ border: A.rgb(55, 45, 85),
68
+ borderDim: A.rgb(35, 30, 55),
69
+
70
+ name: A.rgb(220, 215, 255),
71
+ nameDim: A.slate,
72
+ coins: A.gold,
73
+ level: A.cyan,
74
+ lifesavers: A.pink,
75
+ lifesaversLow: A.crimson,
76
+ lifesaversMid: A.orange,
77
+
78
+ statusActive: A.emerald,
79
+ statusPaused: A.crimson,
80
+ statusWarning: A.orange,
81
+ statusOffline: A.slate,
82
+ statusConnecting: A.yellow,
83
+
84
+ cmdSuccess: A.emerald,
85
+ cmdError: A.crimson,
86
+ cmdEvent: A.purple,
87
+ cmdWarn: A.orange,
88
+ cmdInfo: A.slate,
89
+
90
+ statLabel: A.slate,
91
+ statValue: A.white,
92
+
93
+ // Box styles
94
+ topLeft: '╭', topRight: '╮',
95
+ botLeft: '╰', botRight: '╯',
96
+ h: '─', v: '│',
97
+ };
98
+
99
+ // ── Spinner frames ──────────────────────────────────────────────────────────
100
+
101
+ const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠿'];
102
+
103
+ // ── stdout capture during startup ─────────────────────────────────────────
104
+
105
+ let _origWrite = null;
106
+ let _captureActive = false;
107
+ let _captureBuf = [];
108
+
109
+ function _captureWrite(chunk) {
110
+ if (_captureActive) {
111
+ _captureBuf.push(String(chunk));
112
+ return;
113
+ }
114
+ return _origWrite.call(process.stdout, chunk);
115
+ }
116
+
117
+ // ── Terminal Renderer ────────────────────────────────────────────────────────
118
+
119
+ class Terminal {
120
+ constructor() {
121
+ this.workers = [];
122
+ this.events = [];
123
+ this.MAX_EVENTS = 4;
124
+
125
+ this.phaseName = '';
126
+ this.phaseFrame = 0;
127
+ this.phaseTimer = null;
128
+
129
+ this.dirtyWorkers = new Set();
130
+ this.dirtyEvents = false;
131
+ this.dirtyStats = true;
132
+ this._renderTimer = null;
133
+ this._startTime = 0;
134
+
135
+ this.windowStart = 0;
136
+ this.windowSize = 8;
137
+ this._followIdx = -1;
138
+
139
+ this._lineCount = 0;
140
+ this._active = false;
141
+ this._shutdown = false;
142
+
143
+ this._w = 80;
144
+ this._h = 24;
145
+ this._resizeTimer = null;
146
+
147
+ // ── Startup capture ──
148
+ this._capturing = false;
149
+ this._origLog = null;
150
+ this._phaseProgressDone = 0;
151
+ this._phaseProgressTotal = 0;
152
+ }
153
+
154
+ // ── Public API ──────────────────────────────────────────────────────────
155
+
156
+ init(opts = {}) {
157
+ this._startTime = opts.startTime || Date.now();
158
+ this.workers = opts.workers || [];
159
+ this._updateSize();
160
+
161
+ if (READY) {
162
+ // Override stdout.write to capture everything during startup
163
+ _origWrite = process.stdout.write.bind(process.stdout);
164
+ process.stdout.write = _captureWrite;
165
+ this._capturing = true;
166
+ _captureActive = true;
167
+ _captureBuf = [];
168
+
169
+ // Also override console.log temporarily
170
+ this._origLog = console.log;
171
+ console.log = (...args) => {
172
+ if (this._active) {
173
+ // After activation, route to flashEvent
174
+ const msg = args.join(' ').replace(/\x1b\[[0-9;]*m/g, '').substring(0, 120);
175
+ if (msg.trim()) this.flashEvent('info', msg);
176
+ } else {
177
+ _captureWrite(args.join(' ') + '\n');
178
+ }
179
+ };
180
+
181
+ process.stdout.on('resize', this._onResize.bind(this));
182
+ this._drawStartupScreen();
183
+ }
184
+ }
185
+
186
+ setVersion(v) { this._version = v; }
187
+
188
+ startPhase(name) {
189
+ this.phaseName = name;
190
+ this.phaseFrame = 0;
191
+ this._phaseProgressDone = 0;
192
+ this._phaseProgressTotal = 0;
193
+
194
+ if (!READY) {
195
+ console.log(` ⟳ ${name}...`);
196
+ return;
197
+ }
198
+ if (this.phaseTimer) clearInterval(this.phaseTimer);
199
+ this.phaseTimer = setInterval(() => {
200
+ this.phaseFrame = (this.phaseFrame + 1) % SPIN.length;
201
+ this._redrawPhaseSpinner();
202
+ }, 120);
203
+ this._redrawPhaseSpinner();
204
+ }
205
+
206
+ updateProgress(done, total) {
207
+ this._phaseProgressDone = done;
208
+ this._phaseProgressTotal = total;
209
+ if (!READY) return;
210
+ this._redrawProgressBar();
211
+ }
212
+
213
+ endPhase(name, ok = true) {
214
+ if (this.phaseTimer) { clearInterval(this.phaseTimer); this.phaseTimer = null; }
215
+ this.phaseName = '';
216
+ this._phaseProgressDone = 0;
217
+ this._phaseProgressTotal = 0;
218
+
219
+ if (!READY) {
220
+ const icon = ok ? `✓ ${name}` : `✗ ${name}`;
221
+ console.log(` ${icon}`);
222
+ return;
223
+ }
224
+ const icon = ok ? `${C.header}✓${A.reset}` : `${A.crimson}✗${A.reset}`;
225
+ const line = ` ${icon} ${name}`;
226
+ const w = this._w;
227
+
228
+ // Clear spinner + progress rows, show result
229
+ this._write(
230
+ A.save +
231
+ this._cursor(6, 1) + A.clearLine +
232
+ this._cursor(7, 1) + A.clearLine +
233
+ this._cursor(8, 1) + A.clearLine +
234
+ this._cursor(9, 1) + A.clearLine +
235
+ this._cursor(10, 1) + A.clearLine +
236
+ this._cursor(11, 1) + A.clearLine +
237
+ this._cursor(12, 1) + A.clearLine +
238
+ `${this._rpad(line, w)}` +
239
+ A.restore
240
+ );
241
+ }
242
+
243
+ flashEvent(type, msg) {
244
+ const now = new Date();
245
+ const ts = `${A.dim}${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}${A.reset}`;
246
+ this.events.unshift({ ts, type, msg, id: Date.now() });
247
+ if (this.events.length > this.MAX_EVENTS) this.events.pop();
248
+ this.dirtyEvents = true;
249
+ }
250
+
251
+ setWorkers(workers) {
252
+ this.workers = workers;
253
+ this.dirtyWorkers = new Set(workers.map((_, i) => i));
254
+ this.dirtyStats = true;
255
+ }
256
+
257
+ markWorkerDirty(idx) {
258
+ this.dirtyWorkers.add(idx);
259
+ }
260
+
261
+ setActive() {
262
+ if (this._active) return;
263
+ this._active = true;
264
+
265
+ if (READY) {
266
+ // ── Stop capturing, fully clear screen, draw live view ──
267
+ _captureActive = false;
268
+ this._capturing = false;
269
+
270
+ // Restore stdout.write first
271
+ if (_origWrite) {
272
+ process.stdout.write = _origWrite;
273
+ _origWrite = null;
274
+ }
275
+ if (this._origLog) {
276
+ console.log = this._origLog;
277
+ this._origLog = null;
278
+ }
279
+
280
+ // Discard all buffered startup output — we only want the live view
281
+ _captureBuf = [];
282
+
283
+ // Full screen clear + scrollback clear
284
+ this._write(A.eraseAll);
285
+
286
+ // Draw live view
287
+ this._drawLiveView();
288
+
289
+ // Reset dirty flags so we don't redraw header every frame
290
+ this.dirtyWorkers.clear();
291
+ this.dirtyEvents = false;
292
+ this.dirtyStats = false;
293
+
294
+ this._startRenderLoop();
295
+ } else {
296
+ // Non-TTY: restore console.log
297
+ if (this._origLog) {
298
+ console.log = this._origLog;
299
+ this._origLog = null;
300
+ }
301
+ }
302
+ }
303
+
304
+ scrollBy(delta) {
305
+ if (!READY || this._shutdown) return;
306
+ const max = Math.max(0, this.workers.length - this.windowSize);
307
+ this.windowStart = Math.max(0, Math.min(max, this.windowStart + delta));
308
+ this._followIdx = -1;
309
+ this.dirtyWorkers = new Set();
310
+ for (let i = this.windowStart; i < this.windowStart + this.windowSize; i++) {
311
+ if (i < this.workers.length) this.dirtyWorkers.add(i);
312
+ }
313
+ }
314
+
315
+ shutdown(summary = {}) {
316
+ this._shutdown = true;
317
+ if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; }
318
+ if (this._phaseTimer) { clearInterval(this._phaseTimer); this._phaseTimer = null; }
319
+
320
+ // Restore stdout state
321
+ if (READY && _origWrite) {
322
+ process.stdout.write = _origWrite;
323
+ _origWrite = null;
324
+ }
325
+ if (this._origLog) {
326
+ console.log = this._origLog;
327
+ this._origLog = null;
328
+ }
329
+
330
+ this._write(A.show);
331
+
332
+ const w = this._w;
333
+ const { totalCoins = 0, totalCmds = 0, totalSuccess = 0,
334
+ workers = [], uptime = 0, memMB = 0 } = summary;
335
+
336
+ const b = C.border;
337
+ const h = C.header;
338
+ const g = C.statValue;
339
+ const dim = A.dim;
340
+ const r = A.reset;
341
+
342
+ let out = '';
343
+ out += A.eraseAll + A.home + A.save;
344
+
345
+ // Box top
346
+ out += `${this._at(1, 1)}${b}${C.topLeft}${'─'.repeat(w - 2)}${C.topRight}${r}`;
347
+ out += `${this._at(2, 1)}${b}${C.v} ${h}${A.bold} ⬡ DANKGRINDER — Session Summary ${r}${' '.repeat(Math.max(0, w - 40))}${C.v}${r}`;
348
+ out += `${this._at(3, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`;
349
+
350
+ // Column headers
351
+ const hdr = [
352
+ `${h}#${r}`, `${h}ACCOUNT${r}`, `${h}COINS${r}`,
353
+ `${h}LV${r}`, `${h}♥${r}`, `${h}CMDS${r}`,
354
+ `${h}OK%${r}`, `${h}STATUS${r}`,
355
+ ].join(` `);
356
+ out += `${this._at(4, 1)}${b} ${this._rpad(hdr, w - 4)} ${C.v}${r}`;
357
+ out += `${this._at(5, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`;
358
+
359
+ // Per-account rows
360
+ let row = 6;
361
+ for (let i = 0; i < workers.length && row < this._h - 3; i++) {
362
+ const wk = workers[i];
363
+ const rate = wk.stats?.commands > 0
364
+ ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
365
+ : '0%';
366
+ const ls = wk._lifesavers ?? '?';
367
+ const lsColor = ls === 0 ? C.lifesaversLow : ls <= 2 ? C.lifesaversMid : C.lifesavers;
368
+ const statusIcon = (!wk.running || wk._tokenInvalid) ? '⚫ offline'
369
+ : wk.paused || wk.dashboardPaused ? '🔴 paused'
370
+ : '🟢 active';
371
+
372
+ const line = [
373
+ `${g}${String(i + 1).padEnd(2)}${r}`,
374
+ `${C.name}${A.bold}${(wk.username || '?').substring(0, 18).padEnd(19)}${r}`,
375
+ `${C.coins}⏣${(wk.stats?.coins || 0).toLocaleString().padStart(8)}${r}`,
376
+ `${C.level}Lv.${wk._level ?? '?'}${r}`,
377
+ `${lsColor}♥${String(ls).padStart(2)}${r}`,
378
+ `${g}${String(wk.stats?.commands || 0).padStart(4)}cmds${r}`,
379
+ `${g}${rate.padStart(4)}${r}`,
380
+ `${g}${statusIcon}${r}`,
381
+ ].join(' ');
382
+ out += `${this._at(row++, 1)}${b} ${this._rpad(line, w - 4)} ${C.v}${r}`;
383
+ }
384
+
385
+ row++; // blank
386
+ out += `${this._at(row++, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`;
387
+
388
+ // Totals row
389
+ const totalRate = totalCmds > 0 ? `${((totalSuccess / totalCmds) * 100).toFixed(0)}%` : '0%';
390
+ const uptimeStr = this._fmtUptime(uptime);
391
+ const totalLine = [
392
+ `${h}💰 TOTAL:${r}`,
393
+ `${C.coins}${A.bold}⏣ ${totalCoins.toLocaleString()}${r}`,
394
+ `${dim}${totalCmds} cmds${r}`,
395
+ `${dim}${totalRate} OK${r}`,
396
+ `${dim}${uptimeStr}${r}`,
397
+ `${dim}${memMB}MB RAM${r}`,
398
+ ].join(' ');
399
+ out += `${this._at(row++, 1)}${b} ${this._rpad(totalLine, w - 4)} ${C.v}${r}`;
400
+ out += `${this._at(row, 1)}${b}${C.botLeft}${'─'.repeat(w - 2)}${C.botRight}${r}`;
401
+ out += A.restore + A.show;
402
+
403
+ this._write(out);
404
+ }
405
+
406
+ // ── Internal ────────────────────────────────────────────────────────────
407
+
408
+ _updateSize() {
409
+ try {
410
+ this._w = process.stdout.columns || 80;
411
+ this._h = process.stdout.rows || 24;
412
+ this.windowSize = Math.max(3, this._h - 11);
413
+ } catch (_) {
414
+ this._w = 80; this._h = 24; this.windowSize = 8;
415
+ }
416
+ }
417
+
418
+ _onResize() {
419
+ if (this._resizeTimer) clearTimeout(this._resizeTimer);
420
+ this._resizeTimer = setTimeout(() => {
421
+ this._updateSize();
422
+ if (this._active) {
423
+ this._write(A.eraseAll);
424
+ this._drawLiveView();
425
+ }
426
+ }, 100);
427
+ }
428
+
429
+ _ansiLen(s) {
430
+ let len = 0, i = 0;
431
+ const str = String(s);
432
+ while (i < str.length) {
433
+ if (str.charCodeAt(i) === 0x1b && str[i + 1] === '[') {
434
+ let j = i + 2;
435
+ while (j < str.length && str[j] !== 'm') j++;
436
+ i = j + 1;
437
+ } else { len++; i++; }
438
+ }
439
+ return len;
440
+ }
441
+
442
+ _rpad(s, width) {
443
+ return s + ' '.repeat(Math.max(0, width - this._ansiLen(s)));
444
+ }
445
+
446
+ _cursor(row) { return `\x1b[${row};1H`; }
447
+ _at(row, col) { return `\x1b[${row};${col}H`; }
448
+ _write(str) { if (str) process.stdout.write(str); }
449
+
450
+ _fmtUptime(ms) {
451
+ if (!ms) return '0s';
452
+ const s = Math.floor(ms / 1000);
453
+ if (s < 60) return `${s}s`;
454
+ const m = Math.floor(s / 60);
455
+ if (m < 60) return `${m}m ${s % 60}s`;
456
+ const h = Math.floor(m / 60);
457
+ if (h < 24) return `${h}h ${m % 60}m`;
458
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
459
+ }
460
+
461
+ _fmtCoins(n) {
462
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
463
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
464
+ return String(n);
465
+ }
466
+
467
+ _buildAccountRow(wk, idx) {
468
+ const w = this._w;
469
+ const ls = wk._lifesavers ?? '?';
470
+ const lsColor = ls === 0 ? C.lifesaversLow : ls <= 2 ? C.lifesaversMid : C.lifesavers;
471
+ const rate = wk.stats?.commands > 0
472
+ ? `${((wk.stats.successes / wk.stats.commands) * 100).toFixed(0)}%`
473
+ : '0%';
474
+
475
+ let statusDot, statusText, rowBg;
476
+ if (!wk.running || wk._tokenInvalid) {
477
+ statusDot = '⚫'; statusText = 'offline';
478
+ } else if (wk.paused || wk.dashboardPaused) {
479
+ statusDot = '🔴'; statusText = 'paused';
480
+ } else if (wk.lastStatus?.includes('claim') || wk.lastStatus?.includes('daily')) {
481
+ statusDot = '🟡'; statusText = 'claiming';
482
+ } else {
483
+ statusDot = '🟢'; statusText = 'grinding';
484
+ }
485
+
486
+ const line = [
487
+ `${C.header}${idx + 1}.${A.reset}`,
488
+ `${C.name}${A.bold}${(wk.username || '?').substring(0, 18).padEnd(19)}${A.reset}`,
489
+ `${C.coins}⏣${this._fmtCoins(wk.stats?.coins || 0).padStart(7)}${A.reset}`,
490
+ `${C.level}Lv.${String(wk._level ?? '?').padStart(3)}${A.reset}`,
491
+ `${lsColor}♥${String(ls).padStart(2)}${A.reset}`,
492
+ `${C.statValue}${String(wk.stats?.commands || 0).padStart(4)}cmds${A.reset}`,
493
+ `${C.statValue}${rate.padStart(5)}${A.reset}`,
494
+ `${statusDot} ${statusText}`.padEnd(14),
495
+ `${A.dim}${(wk.lastStatus || '').substring(0, 22).padEnd(22)}${A.reset}`,
496
+ ].join(' ');
497
+
498
+ return this._rpad(line, w);
499
+ }
500
+
501
+ _drawStartupScreen() {
502
+ const w = this._w;
503
+ const b = C.border;
504
+ const h = C.header;
505
+ const dim = A.dim;
506
+ const r = A.reset;
507
+
508
+ let out = '';
509
+ out += A.eraseAll + A.home;
510
+ out += `${this._at(1, 1)}${b}${C.topLeft}${'─'.repeat(w - 2)}${C.topRight}${r}`;
511
+ out += `${this._at(2, 1)}${b} ${h}${A.bold} ⬡ DANKGRINDER v${this._version || '?'} ${r}${'─'.repeat(Math.max(0, w - 28 - (this._version || '').length))}${r}`;
512
+ out += `${this._at(3, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`;
513
+ // Status bar placeholder
514
+ out += `${this._at(4, 1)}${b}${' '.repeat(w - 2)}${r}`;
515
+ out += `${this._at(5, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`;
516
+ // Spinner area
517
+ out += `${this._at(7, 1)}${b}${' '.repeat(w - 2)}${r}`;
518
+ out += `${this._at(8, 1)}${b}${' '.repeat(w - 2)}${r}`;
519
+ out += `${this._at(9, 1)}${b}${' '.repeat(w - 2)}${r}`;
520
+ out += `${this._at(10, 1)}${b}${' '.repeat(w - 2)}${r}`;
521
+ out += `${this._at(11, 1)}${b}${' '.repeat(w - 2)}${r}`;
522
+ out += `${this._at(12, 1)}${b}${' '.repeat(w - 2)}${r}`;
523
+ // Footer
524
+ out += `${this._at(14, 1)}${b}${C.botLeft}${'─'.repeat(w - 2)}${C.botRight}${r}`;
525
+ out += `${this._at(15, 1)}${b} ${dim}Starting up...${r}${' '.repeat(Math.max(0, w - 18))}${r}`;
526
+
527
+ this._write(out);
528
+ }
529
+
530
+ _redrawPhaseSpinner() {
531
+ if (!READY || !this._phaseName) return;
532
+ const frame = SPIN[this.phaseFrame];
533
+ const line = ` ${frame} ${this.phaseName}...`;
534
+ const w = this._w;
535
+ const b = C.border;
536
+ const r = A.reset;
537
+ this._write(
538
+ A.save +
539
+ this._cursor(8, 1) + A.clearLine +
540
+ `${b} ${line}${' '.repeat(Math.max(0, w - this._ansiLen(line) - 3))}${r}` +
541
+ A.restore
542
+ );
543
+ }
544
+
545
+ _redrawProgressBar() {
546
+ if (!READY || !this._phaseName) return;
547
+ const { done, total } = { done: this._phaseProgressDone, total: this._phaseProgressTotal };
548
+ const w = this._w;
549
+ const h = C.header;
550
+ const dim = A.dim;
551
+ const r = A.reset;
552
+
553
+ const barW = Math.max(10, w - 35);
554
+ const filled = total > 0 ? Math.round((done / total) * barW) : 0;
555
+ const bar = `${h}${'█'.repeat(filled)}${dim}${'░'.repeat(barW - filled)}${r}`;
556
+ const label = ` ${done}/${total} `;
557
+ const line = bar + label;
558
+ this._write(
559
+ A.save +
560
+ this._cursor(9, 1) + A.clearLine +
561
+ `${C.border} ${line}${' '.repeat(Math.max(0, w - this._ansiLen(line) - 3))}${r}` +
562
+ A.restore
563
+ );
564
+ }
565
+
566
+ _drawLiveView() {
567
+ this._drawHeader();
568
+ this._drawAccounts();
569
+ this._drawEvents();
570
+ this._drawFooter();
571
+ }
572
+
573
+ _drawHeader() {
574
+ const w = this._w;
575
+ const b = C.border;
576
+ const h = C.header;
577
+ const g = C.statValue;
578
+ const dim = A.dim;
579
+ const r = A.reset;
580
+
581
+ // Title bar
582
+ const titleText = ` ⬡ DANKGRINDER v${this._version || '?'} `;
583
+ const titlePad = Math.max(0, w - 2 - this._ansiLen(titleText));
584
+ this._write(`${this._at(1, 1)}${b}${C.topLeft}${'─'.repeat(w - 2)}${C.topRight}${r}`);
585
+ this._write(`${this._at(2, 1)}${b} ${h}${A.bold}${titleText}${r}${' '.repeat(titlePad)} ${C.v}${r}`);
586
+ this._write(`${this._at(3, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`);
587
+
588
+ // Stats bar
589
+ const stats = this._buildStatsLine();
590
+ const statsPad = Math.max(0, w - 2 - this._ansiLen(stats) - 2);
591
+ this._write(`${this._at(4, 1)}${b} ${stats}${' '.repeat(statsPad)} ${C.v}${r}`);
592
+ this._write(`${this._at(5, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`);
593
+
594
+ this._accountsRow = 6;
595
+ }
596
+
597
+ _buildStatsLine() {
598
+ let totalCoins = 0, totalCmds = 0, totalSuccess = 0, totalLs = 0;
599
+ let paused = 0, active = 0;
600
+
601
+ for (const wk of this.workers) {
602
+ totalCoins += wk.stats?.coins || 0;
603
+ totalCmds += wk.stats?.commands || 0;
604
+ totalSuccess += wk.stats?.successes || 0;
605
+ if (wk._lifesavers != null) totalLs += wk._lifesavers;
606
+ if (wk.running && !wk._tokenInvalid) {
607
+ if (wk.paused || wk.dashboardPaused) paused++;
608
+ else active++;
609
+ }
610
+ }
611
+
612
+ const uptime = this._fmtUptime(Date.now() - this._startTime);
613
+ const rate = totalCmds > 0 ? ((totalSuccess / totalCmds) * 100).toFixed(0) : '0';
614
+
615
+ return [
616
+ `${A.dim}⏱${A.reset} ${C.statValue}${uptime}${A.reset}`,
617
+ `${A.dim}⬡${A.reset} ${C.statValue}${this.workers.length}${A.reset} ${A.dim}accounts${A.reset}`,
618
+ `${C.coins}⏣${A.reset} ${C.statValue}${totalCoins.toLocaleString()}${A.reset}`,
619
+ `${A.dim}⚡${A.reset} ${C.statValue}${totalCmds}${A.reset} ${A.dim}cmds${A.reset}`,
620
+ `${A.dim}📊${A.reset} ${C.statValue}${rate}%${A.reset}`,
621
+ `${C.lifesavers}♥${A.reset} ${C.statValue}${totalLs}${A.reset}`,
622
+ `${C.statusActive}🟢${A.reset} ${C.statValue}${active}${A.reset}`,
623
+ `${C.statusPaused}🔴${A.reset} ${C.statValue}${paused}${A.reset}`,
624
+ ].join(` │ `);
625
+ }
626
+
627
+ _drawAccounts() {
628
+ const w = this._w;
629
+ const b = C.border;
630
+ const h = C.header;
631
+ const r = A.reset;
632
+
633
+ // Column headers
634
+ const cols = [
635
+ `${h}#${r}`, `${h}ACCOUNT${r}`, `${h}COINS${r}`,
636
+ `${h}LV${r}`, `${h}♥${r}`, `${h}CMDS${r}`,
637
+ `${h}OK%${r}`, `${h}STATUS${r}`,
638
+ ].join(` `);
639
+ this._write(`${this._at(this._accountsRow, 1)}${b} ${this._rpad(cols, w - 4)} ${C.v}${r}`);
640
+ this._write(`${this._at(this._accountsRow + 1, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`);
641
+
642
+ const visible = this.workers.slice(this.windowStart, this.windowStart + this.windowSize);
643
+ for (let i = 0; i < this.windowSize; i++) {
644
+ const row = this._accountsRow + 2 + i;
645
+ if (row > this._h - 4) break;
646
+ if (i < visible.length) {
647
+ const line = this._buildAccountRow(visible[i], this.windowStart + i);
648
+ this._write(`${this._at(row, 1)}${b} ${line} ${C.v}${r}`);
649
+ } else {
650
+ this._write(`${this._at(row, 1)}${b}${' '.repeat(w - 2)}${r}`);
651
+ }
652
+ }
653
+ this._eventsRow = this._accountsRow + 2 + Math.min(this.windowSize, this.workers.length);
654
+ }
655
+
656
+ _drawEvents() {
657
+ const w = this._w;
658
+ const b = C.border;
659
+ const dim = A.dim;
660
+ const r = A.reset;
661
+
662
+ if (this._eventsRow > this._h - 4) return;
663
+ this._write(`${this._at(this._eventsRow, 1)}${b}${C.h}${'─'.repeat(w - 2)}${C.h}${r}`);
664
+
665
+ const visible = this.events.slice(0, Math.min(this.MAX_EVENTS, this._h - this._eventsRow - 3));
666
+ for (let i = 0; i < visible.length; i++) {
667
+ const row = this._eventsRow + 1 + i;
668
+ if (row > this._h - 2) break;
669
+ const e = visible[i];
670
+ const typeColor = e.type === 'death' ? C.cmdError
671
+ : e.type === 'lowls' ? C.cmdWarn
672
+ : e.type === 'levelup' ? C.cmdSuccess
673
+ : e.type === 'success' ? C.cmdSuccess
674
+ : C.cmdInfo;
675
+ const line = ` ${dim}${e.ts}${A.reset} ${typeColor}${e.msg}${A.reset}`;
676
+ this._write(`${this._at(row, 1)}${b} ${this._rpad(line, w - 4)} ${C.v}${r}`);
677
+ }
678
+ this._footerRow = this._eventsRow + 1 + visible.length;
679
+ }
680
+
681
+ _drawFooter() {
682
+ const w = this._w;
683
+ const b = C.border;
684
+ const dim = A.dim;
685
+ const r = A.reset;
686
+ if (this._footerRow > this._h - 1) this._footerRow = this._h - 2;
687
+ this._write(`${this._at(this._footerRow, 1)}${b}${C.botLeft}${'─'.repeat(w - 2)}${C.botRight}${r}`);
688
+ const hint = `${dim}↑↓ scroll${r}`;
689
+ this._write(`${this._at(this._footerRow + 1, 1)}${b} ${hint}${' '.repeat(Math.max(0, w - 2 - this._ansiLen(hint)))}${r}`);
690
+ }
691
+
692
+ _render() {
693
+ if (!READY || this._shutdown || !this._active) return;
694
+
695
+ // Only update stats line (row 4) when dirty — don't redraw whole header
696
+ if (this.dirtyStats) {
697
+ const w = this._w;
698
+ const b = C.border;
699
+ const stats = this._buildStatsLine();
700
+ const statsPad = Math.max(0, w - 2 - this._ansiLen(stats) - 2);
701
+ this._write(`${this._at(4, 1)}${b} ${stats}${' '.repeat(statsPad)} ${C.v}${A.reset}`);
702
+ this.dirtyStats = false;
703
+ }
704
+
705
+ if (this.dirtyWorkers.size > 0) {
706
+ const w = this._w;
707
+ const b = C.border;
708
+ const r = A.reset;
709
+ for (const idx of this.dirtyWorkers) {
710
+ const localIdx = idx - this.windowStart;
711
+ const row = this._accountsRow + 2 + localIdx;
712
+ if (row < this._accountsRow + 2 || row > this._h - 4) continue;
713
+ if (idx < this.workers.length) {
714
+ const line = this._buildAccountRow(this.workers[idx], idx);
715
+ this._write(`${this._at(row, 1)}${b} ${line} ${C.v}${r}`);
716
+ } else {
717
+ this._write(`${this._at(row, 1)}${b}${' '.repeat(w - 2)}${r}`);
718
+ }
719
+ }
720
+ }
721
+
722
+ if (this.dirtyEvents) {
723
+ this._drawEvents();
724
+ }
725
+
726
+ this.dirtyWorkers.clear();
727
+ this.dirtyEvents = false;
728
+ }
729
+
730
+ _startRenderLoop() {
731
+ if (this._renderTimer) clearInterval(this._renderTimer);
732
+ this._renderTimer = setInterval(() => {
733
+ this.dirtyStats = true; // always refresh uptime/stats
734
+ this._render();
735
+ }, 250);
736
+ }
737
+ }
738
+
739
+ module.exports = new Terminal();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dankgrinder",
3
- "version": "7.77.0",
3
+ "version": "7.79.0",
4
4
  "description": "Dank Memer automation engine — grind coins while you sleep",
5
5
  "bin": {
6
6
  "dankgrinder": "bin/dankgrinder.js"