solana-tui-explorer 1.0.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.
@@ -0,0 +1,2199 @@
1
+ // =============================================
2
+ // SOLANA TUI EXPLORER CLI · v4
3
+ // Theme: green/cyan on black (classic hacker terminal)
4
+ // Charts: solid-fill bar chart matching reference image
5
+ // =============================================
6
+
7
+ const blessed = require('blessed');
8
+ const contrib = require('blessed-contrib');
9
+ const chalk = require('chalk');
10
+ const readline = require('readline');
11
+ chalk.level = 3;
12
+
13
+ const { DATA, loadMarketData, loadNetworkData, loadWalletData, loadTokenData, loadNewsData } = require('../data');
14
+ const CFG = require('../config');
15
+
16
+ // Embedded land grid (120x38)
17
+ const LAND_HEX = [
18
+ "000000007c07f00000000000000000",
19
+ "00000077effff800c0000006000000",
20
+ "00001808007ff8000002007f800000",
21
+ "03fdf7dcbc3ff0003e00bfffffffa6",
22
+ "9fffffffffffffff10800000000000",
23
+ "07bffff838040003dfffffffffff38",
24
+ "0000fffe3f0000209fffffffffc0c0",
25
+ "00007fffbfc00037fffffffffff000",
26
+ "00001ffff440001fffffffffffd000",
27
+ "00001ffff0000078b837ffffff3000",
28
+ "00001fffc000007017f3ffffe62000",
29
+ "000007ffc000007f00fffffff18000",
30
+ "000001f08000007ffff7fffff80000",
31
+ "000000f0000001fffffa3fffe80000",
32
+ "00000072200001ffff7e0f9e000000",
33
+ "00000007000003ffffb8060f000000",
34
+ "00000001100001ffffc80603040000",
35
+ "000000003f8000fffff00004040000",
36
+ "000000003fe00001ffe00002600000",
37
+ "000000007ff80001ffc0000260c000",
38
+ "000000007fff0000ff800001003800",
39
+ "000000003fff00007f800000000000",
40
+ "000000001ffe0000ff980000079000",
41
+ "0000000007fe0000ff1000000ff800",
42
+ "0000000007f000007e1000003ffe00",
43
+ "000000000ff000003e0000003ffe00",
44
+ "000000000fc0000038000000387c00",
45
+ "000000000f80000000000000001802",
46
+ "000000001e00000000000000000804",
47
+ "000000001c00000000000000000000",
48
+ "000000001880000000000000000000",
49
+ "000000000000000000000000000000",
50
+ "000000000000000000000000000000",
51
+ "000000000000000000000000000000",
52
+ "000000000e00000001ffe3ffffff80",
53
+ "0000003fff0001fffffffffffffff0",
54
+ "02ffffffc008ffffffffffffffffe0",
55
+ "007fffffffffffffffffffffffffe0"
56
+ ];
57
+ const GRA_W = 120, GRA_H = 38;
58
+ const GRA_LAND = LAND_HEX.map(hex => {
59
+ const bits = [];
60
+ for (let i = 0; i < hex.length; i += 2) {
61
+ const byte = parseInt(hex.slice(i, i+2), 16);
62
+ for (let b = 7; b >= 0; b--) bits.push((byte >> b) & 1);
63
+ }
64
+ return bits.slice(0, GRA_W);
65
+ });
66
+
67
+ // Internal RPC helper from api.js — used by live section directly
68
+ const { fetchEpochInfo: _fetchEpochInfo } = require('../api');
69
+ // rpcCall helper re-exposed for live section's pollLiveStats
70
+ const https = require('https');
71
+ function rpcCall(method, params = []) {
72
+ return new Promise((resolve, reject) => {
73
+ const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
74
+ const rpcUrl = new URL(CFG.SOLANA_RPC);
75
+ const options = {
76
+ hostname: rpcUrl.hostname,
77
+ path: rpcUrl.pathname + rpcUrl.search,
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'User-Agent': 'SolanaTUIExplorer/1.0' },
80
+ timeout: 10000,
81
+ };
82
+ const req = https.request(options, res => {
83
+ let data = '';
84
+ res.on('data', c => data += c);
85
+ res.on('end', () => {
86
+ try { const j = JSON.parse(data); if (j.error) reject(new Error(j.error.message)); else resolve(j.result); }
87
+ catch (e) { reject(e); }
88
+ });
89
+ });
90
+ req.on('error', reject);
91
+ req.on('timeout', () => { req.destroy(); reject(new Error('RPC timeout')); });
92
+ req.write(body); req.end();
93
+ });
94
+ }
95
+
96
+ // ─────────────────────────────────────────────
97
+ // COLOR PALETTE (green/cyan theme)
98
+ // G = neon green — positive / key values
99
+ // C = cyan — labels, axes, info
100
+ // W = white bold — primary data
101
+ // WW = white — secondary data
102
+ // Y = gold/yellow — accent headers, stats
103
+ // DN = soft red — negative / down
104
+ // GRY = light gray — dim text (always visible)
105
+ // ─────────────────────────────────────────────
106
+ const G = s => `{#00FF88-fg}${String(s)}{/}`;
107
+ const C = s => `{#00FFFF-fg}${String(s)}{/}`;
108
+ const W = s => `{white-fg}{bold}${String(s)}{/}`;
109
+ const WW = s => `{white-fg}${String(s)}{/}`;
110
+ const Y = s => `{#FFD700-fg}${String(s)}{/}`;
111
+ const DN = s => `{#FF6B6B-fg}${String(s)}{/}`;
112
+ const GRY = s => `{white-fg}${String(s)}{/}`;
113
+ // Aliases for header/accent usage
114
+ const O = Y;
115
+ const OB = s => `{#FFD700-fg}{bold}${String(s)}{/}`;
116
+ const LBL = C;
117
+
118
+ // Badges
119
+ const GRN_BG = s => `{#006600-bg}{white-fg}{bold} ${String(s)} {/}`;
120
+ const RED_BG = s => `{#880000-bg}{white-fg}{bold} ${String(s)} {/}`;
121
+ const YEL_BG = s => `{#885500-bg}{white-fg}{bold} ${String(s)} {/}`;
122
+ const TL_BG = s => `{#005566-bg}{white-fg}{bold} ${String(s)} {/}`; // teal badge
123
+
124
+ // Separator line — cyan
125
+ const HR = (w = 90) => `{#00FFFF-fg}${'-'.repeat(w)}{/}`;
126
+
127
+ // ─────────────────────────────────────────────
128
+ // ALWAYS put fg:'white' on every box so text
129
+ // never inherits a potentially-black terminal default.
130
+ // ─────────────────────────────────────────────
131
+ const BOX = { bg: 'black', fg: 'white' };
132
+ const BCYAN = { type: 'line', fg: '#00FFFF' };
133
+ const BDIM = { type: 'line', fg: '#005566' };
134
+
135
+ // ─────────────────────────────────────────────
136
+ // STATE & SIMULATION ENGINE
137
+ // ─────────────────────────────────────────────
138
+ let simulatedSlot = 0;
139
+ let heartbeatInterval = null;
140
+ let current = 0; // Global tab state tracker
141
+ let validatorGeoData = null;
142
+ let validatorGeoLoading = false;
143
+
144
+ // startHeartbeat is defined inside startDashboard() where
145
+ // buildNetworkTab and screen are in scope. This stub is intentionally empty.
146
+ function startHeartbeat() { /* real impl inside startDashboard */ }
147
+
148
+ // Progress Bar
149
+ function progressBar(pct, width = 20) {
150
+ const filled = Math.round(Math.max(0, Math.min(100, pct || 0)) / 100 * width);
151
+ const empty = Math.max(0, width - filled);
152
+ return `{#00FF88-fg}${'█'.repeat(filled)}{/}{#114422-fg}${'░'.repeat(empty)}{/}`;
153
+ }
154
+
155
+ // ─────────────────────────────────────────────
156
+ // FORMATTERS
157
+ // ─────────────────────────────────────────────
158
+ const pad = (s, n) => String(s == null ? '-' : s).padEnd(n);
159
+ const fmtPrice = v => {
160
+ if (!v || isNaN(v)) return '-';
161
+ if (v >= 1000) return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
162
+ if (v >= 1) return '$' + v.toFixed(2);
163
+ if (v >= 0.001) return '$' + v.toFixed(5);
164
+ return '$' + v.toExponential(3);
165
+ };
166
+ const fmtPct = v => (v > 0 ? '+' : '') + (v || 0).toFixed(2) + '%';
167
+ const fmtVol = s => (s && s !== '-') ? '$' + s : '-';
168
+ const nowTime = () => new Date().toLocaleTimeString('en-US', { hour12: false });
169
+
170
+ // ─────────────────────────────────────────────
171
+ // PROGRESS BAR
172
+ // ─────────────────────────────────────────────
173
+ // (Already defined above)
174
+
175
+ // ─────────────────────────────────────────────
176
+ // FILLED BAR CHART
177
+ // Solid █ columns, NO gap → clean area-chart look
178
+ // matching the reference image exactly.
179
+ // ─────────────────────────────────────────────
180
+ function barFillChart(values, labels, opts = {}) {
181
+ const {
182
+ height = 10,
183
+ colW = 2, // 2-char wide columns
184
+ gap = 0, // no gap → solid wall of bars
185
+ axisW = 7,
186
+ colTag = '#00FF88-fg',
187
+ axisTag = '#00FFFF-fg',
188
+ style = 'solid', // 'solid' or 'dot'
189
+ } = opts;
190
+
191
+ if (!values || values.length < 2 || values.every(v => !v)) {
192
+ return `{white-fg} (no chart data available)\n{/}`;
193
+ }
194
+
195
+ const n = values.length;
196
+ const lo = Math.min(...values);
197
+ const hi = Math.max(...values);
198
+ const rng = (hi - lo) === 0 ? (hi || 1) : (hi - lo);
199
+
200
+ // barH: rows filled from bottom (1 = just base, height = full column)
201
+ const getBarH = v => Math.max(1, Math.round((v - lo) / rng * (height - 1)) + 1);
202
+
203
+ const fmtV = v => {
204
+ const a = Math.abs(v);
205
+ if (a >= 10000) return (v / 1000).toFixed(0) + 'k';
206
+ if (a >= 1000) return Math.round(v).toString();
207
+ if (a >= 10) return v.toFixed(0);
208
+ if (a >= 1) return v.toFixed(1);
209
+ if (a >= 0.1) return v.toFixed(2);
210
+ if (a >= 0.001) return v.toFixed(4);
211
+ if (a === 0) return '0';
212
+ return v.toExponential(2);
213
+ };
214
+
215
+ let out = '';
216
+ for (let r = 0; r < height; r++) {
217
+ const rfb = height - 1 - r; // rows from bottom: 0=bottom, height-1=top
218
+ const rowVal = lo + (rfb / (height - 1)) * rng;
219
+ out += `{${axisTag}}${fmtV(rowVal).padStart(axisW - 1)}\u2502{/}`;
220
+ for (let i = 0; i < n; i++) {
221
+ if (i > 0 && gap > 0) out += ' '.repeat(gap);
222
+ const bh = getBarH(values[i]);
223
+ if (style === 'dot') {
224
+ out += rfb === (bh - 1)
225
+ ? `{${colTag}}•${' '.repeat(colW - 1)}{/}`
226
+ : ' '.repeat(colW);
227
+ } else {
228
+ out += rfb < bh
229
+ ? `{${colTag}}${'█'.repeat(colW)}{/}`
230
+ : ' '.repeat(colW);
231
+ }
232
+ }
233
+ out += '\n';
234
+ }
235
+
236
+ // Baseline
237
+ const totalW = n * colW + (gap > 0 ? n * gap - gap : 0);
238
+ out += ' '.repeat(axisW) + `{${axisTag}}\u2514${'─'.repeat(totalW)}{/}\n`;
239
+
240
+ // X labels — show every Nth only
241
+ if (labels && labels.length) {
242
+ const step = Math.max(1, Math.ceil(n / 12));
243
+ out += ' '.repeat(axisW + 1);
244
+ for (let i = 0; i < n; i++) {
245
+ if (i > 0 && gap > 0) out += ' '.repeat(gap);
246
+ if (i % step === 0) {
247
+ const l = String(labels[i] || '').substring(0, colW).padEnd(colW);
248
+ out += `{${axisTag}}${l}{/}`;
249
+ } else {
250
+ out += ' '.repeat(colW);
251
+ }
252
+ }
253
+ out += '\n';
254
+ }
255
+
256
+ return out;
257
+ }
258
+
259
+ // ─────────────────────────────────────────────
260
+ // ASCII WORLD MAP & LEADER SIDEBAR
261
+ // ─────────────────────────────────────────────
262
+ function buildAsciiWorldMap(geoPoints, leaders = []) {
263
+ const MAP_W = 92;
264
+ const MAP_H = 22;
265
+ const SIDE_W = 28;
266
+ const LAT_MAX = 75;
267
+ const LAT_MIN = -55;
268
+ const LON_MIN = -180;
269
+ const LON_MAX = 180;
270
+
271
+ const latToRow = lat => Math.max(0, Math.min(MAP_H - 1, Math.round((LAT_MAX - lat) / (LAT_MAX - LAT_MIN) * (MAP_H - 1))));
272
+ const lonToCol = lon => Math.max(0, Math.min(MAP_W - 1, Math.round((lon - LON_MIN) / (LON_MAX - LON_MIN) * (MAP_W - 1))));
273
+
274
+ const LAND = [
275
+ [-168,-140, 60, 72], [-140,-120, 54, 60], [-120, -95, 49, 60],
276
+ [-95, -75, 43, 50], [-75, -55, 47, 58], [-55, -52, 46, 52],
277
+ [-125, -100, 35, 50], [-100, -80, 25, 45], [-80, -60, 30, 47],
278
+ [-120, -86, 15, 30], [-90, -77, 8, 18], [-84, -66, 9, 22],
279
+ [-170, -155, 55, 65],
280
+ [-82, -34, -5, 12], [-81, -50,-30, 5], [-73, -34,-57,-28],
281
+ [-68, -40,-55,-25], [-80, -72,-55,-42],
282
+ [-10, 35, 35, 72], [-5, 30, 44, 65], [10, 30, 55, 72],
283
+ [20, 40, 57, 70], [28, 32, 36, 42], [15, 25, 38, 42],
284
+ [5, 15, 42, 47], [-5, 8, 43, 48],
285
+ [-18, 50,-35, 38], [-18, 10, 4, 16], [10, 42,-10, 15],
286
+ [28, 40, 0, 12], [40, 52, 2, 15], [38, 52,-12, 5],
287
+ [12, 40,-36,-12],
288
+ [28, 50, 40, 72], [50, 100, 50, 72], [100, 140, 52, 72],
289
+ [140, 180, 50, 72], [130, 170, 42, 58], [108, 135, 18, 52],
290
+ [60, 100, 22, 52], [44, 65, 28, 42], [52, 80, 8, 28],
291
+ [66, 80, 8, 14],
292
+ [95, 110, 0, 22], [100, 120, 0, 15], [105, 120, -8, 5],
293
+ [115, 125, -4, 2], [120, 142, -8, 2],
294
+ [124, 132, 34, 42], [130, 146, 31, 46], [88, 101, 15, 28],
295
+ [113, 154,-44,-10], [144, 180,-45,-15], [166, 178,-47,-34],
296
+ [-52, -17, 60, 84], [-25, -13, 63, 65], [-25, -13, 63, 67]
297
+ ];
298
+
299
+ const grid = Array.from({ length: MAP_H }, () => new Uint8Array(MAP_W));
300
+ for (const [minLon, maxLon, minLat, maxLat] of LAND) {
301
+ const r1 = latToRow(maxLat), r2 = latToRow(minLat);
302
+ const c1 = lonToCol(minLon), c2 = lonToCol(maxLon);
303
+ for (let r = Math.min(r1,r2); r <= Math.max(r1,r2); r++)
304
+ for (let c = Math.min(c1,c2); c <= Math.max(c1,c2); c++)
305
+ grid[r][c] = 1;
306
+ }
307
+
308
+ // --- Sidebar Logic ---
309
+ const geo = validatorGeoData || {};
310
+ const schedule = geo.leaderSchedule || [];
311
+ const baseSlot = geo.currentSlot || 0;
312
+ const geoLeaders = geo.leaders || [];
313
+
314
+ const offset = simulatedSlot > 0 ? Math.max(0, simulatedSlot - baseSlot) : 0;
315
+ const currentPubkey = schedule[offset] || null;
316
+
317
+ // We use geoPoints natively now to project all identity markers
318
+ // leaderGrid has been completely deprecated in the new string algo
319
+
320
+ const resolveLeader = (pubkey) => {
321
+ if (!pubkey) return null;
322
+ const found = geoLeaders.find(l => l.pubkey === pubkey);
323
+ return found || { name: pubkey.slice(0,6) + '…' + pubkey.slice(-4), city: '' };
324
+ };
325
+
326
+ const cur = resolveLeader(currentPubkey);
327
+ const nextPubkeys = [];
328
+ const seen = new Set([currentPubkey]);
329
+ for (let i = offset + 1; i < Math.min(schedule.length, offset + 20) && nextPubkeys.length < 4; i++) {
330
+ const p = schedule[i];
331
+ if (p && !seen.has(p)) { nextPubkeys.push(p); seen.add(p); }
332
+ }
333
+
334
+ // Build Sidebar text lines (width 32)
335
+ const padRight = (str, len) => str + ' '.repeat(Math.max(0, len - String(str).replace(/\{[^}]+\}/g, '').length));
336
+
337
+ const clusterTotal = geo.totalNodes || 0;
338
+ const vTotal = DATA.networkStats?.validators?.length || clusterTotal;
339
+ const rpcCount = geo.rpcNodes !== undefined ? geo.rpcNodes : Math.max(0, clusterTotal - vTotal);
340
+
341
+ let sb = [];
342
+ sb.push(` {#00FFFF-fg}{bold}${vTotal}{/} {white-fg}Validators{/}`);
343
+ sb.push(` {#00FFFF-fg}{bold}${rpcCount}{/} {white-fg}RPC Nodes{/}`);
344
+ sb.push('');
345
+ sb.push(` {#FFFFFF-bg}{#000000-fg} ⬡ SLOT ${simulatedSlot.toLocaleString()} {/}`);
346
+ sb.push('');
347
+ sb.push(' {white-fg}Current Leader{/}');
348
+ if (cur) {
349
+ sb.push(` {#00FFaa-fg}{bold} ◉ ${cur.name}{/}`);
350
+ if (cur.city && cur.city !== '??') sb.push(` {#00FFFF-fg}${cur.city}{/}`);
351
+ else sb.push('');
352
+ } else {
353
+ sb.push(' {white-fg}Loading...{/}');
354
+ sb.push('');
355
+ }
356
+ sb.push('');
357
+ sb.push(' {white-fg}Next Leaders{/}');
358
+ nextPubkeys.forEach(p => {
359
+ const l = resolveLeader(p);
360
+ sb.push(` {#FFFFFF-fg} › ${l.name}{/}`);
361
+ });
362
+
363
+ while (sb.length < MAP_H) sb.push('');
364
+
365
+ // --- Render to String Map ---
366
+ let out = '\n';
367
+
368
+ const LAND_DOT = '\u25cf';
369
+ const NODE_VAL = '\u25cf';
370
+ const SPOTLIGHT = '\u272A'; // ✪ (Circled Star)
371
+ const C_LAND = '{#4a6b8a-fg}'; // Exact blue-gray from gra.js
372
+ const C_END = '{/}';
373
+ const C_SPOT = '{yellow-fg}';
374
+ const C_VAL = '{cyan-fg}';
375
+
376
+ const mapCols = Math.floor(MAP_W / 2);
377
+ const mapRows = Math.min(MAP_H, GRA_H);
378
+
379
+ const geoGrid = {};
380
+ let currentFound = false;
381
+ for (const p of geoPoints) {
382
+ if (p.lat && p.lon) {
383
+ let col = Math.round((p.lon + 179) / 358 * (GRA_W - 1));
384
+ let row = Math.round((83 - p.lat) / 166 * (GRA_H - 1));
385
+ let rc = Math.round(col / GRA_W * mapCols);
386
+ let rr = Math.round(row / GRA_H * mapRows);
387
+
388
+ let existing = geoGrid[`${rr},${rc}`];
389
+ let isCur = p.pubkey === currentPubkey;
390
+ if (isCur) currentFound = true;
391
+ if (!existing || isCur) {
392
+ geoGrid[`${rr},${rc}`] = { ...p, isCurrent: isCur };
393
+ }
394
+ }
395
+ }
396
+
397
+ // If the current leader is not in our Top 200 resolved GeoIP subset,
398
+ // map them deterministically via pubkey hash so the simulation heartbeat never dies.
399
+ if (currentPubkey && !currentFound) {
400
+ const hash = currentPubkey.split('').reduce((a,b) => a + b.charCodeAt(0), 0);
401
+ const fallbacks = [
402
+ {lat: 40.71, lon: -74.01}, {lat: 37.77, lon: -122.41}, {lat: 51.51, lon: -0.13},
403
+ {lat: 35.68, lon: 139.69}, {lat: 1.35, lon: 103.82}, {lat: -33.87, lon: 151.21},
404
+ {lat: 52.52, lon: 13.40}, {lat: 48.86, lon: 2.35}, {lat: 22.28, lon: 114.16}
405
+ ];
406
+ let fb = fallbacks[hash % fallbacks.length];
407
+ let col = Math.round((fb.lon + 179) / 358 * (GRA_W - 1));
408
+ let row = Math.round((83 - fb.lat) / 166 * (GRA_H - 1));
409
+ let rc = Math.round(col / GRA_W * mapCols);
410
+ let rr = Math.round(row / GRA_H * mapRows);
411
+ geoGrid[`${rr},${rc}`] = { pubkey: currentPubkey, isCurrent: true };
412
+ }
413
+
414
+ out += ` {white-fg}┌${'─'.repeat(SIDE_W)}┬${'─'.repeat(MAP_W)}┐{/}\n`;
415
+ for (let r = 0; r < mapRows; r++) {
416
+ const rawSbLine = sb[r] || '';
417
+ const plainLen = rawSbLine.replace(/\{[^}]+\}/g, '').length;
418
+ const padding = ' '.repeat(Math.max(0, SIDE_W - plainLen - 1));
419
+ out += ` {white-fg}│{/}${rawSbLine}${padding}{white-fg}│{/}`;
420
+
421
+ for (let c = 0; c < mapCols; c++) {
422
+ const gc = Math.round(c / mapCols * GRA_W);
423
+ const gr = Math.round(r / mapRows * GRA_H);
424
+ const isLand = GRA_LAND[gr] && GRA_LAND[gr][gc];
425
+
426
+ let isSpot = false;
427
+ let isNode = false;
428
+
429
+ const node = geoGrid[`${r},${c}`];
430
+ if (node) {
431
+ isNode = true;
432
+ if (node.isCurrent) isSpot = true;
433
+ }
434
+
435
+ if (isSpot) {
436
+ out += C_SPOT + SPOTLIGHT + ' ' + C_END;
437
+ } else if (isNode) {
438
+ out += C_VAL + NODE_VAL + ' ' + C_END;
439
+ } else if (isLand) {
440
+ out += C_LAND + LAND_DOT + ' ' + C_END;
441
+ } else {
442
+ out += ' ';
443
+ }
444
+ }
445
+ out += `{white-fg}│{/}\n`;
446
+ }
447
+ out += ` {white-fg}└${'─'.repeat(SIDE_W)}┴${'─'.repeat(MAP_W)}┘{/}\n`;
448
+ return out;
449
+ } // end buildAsciiWorldMap
450
+
451
+
452
+ // ─────────────────────────────────────────────
453
+ // CANDLESTICK CHART
454
+ // Native OHLC ASCII rendering with Wicks and Bodies
455
+ // ─────────────────────────────────────────────
456
+ function candleChart(candles, opts = {}) {
457
+ const { height = 10, colW = 1, gap = 1, axisW = 9, axisTag = '#005533-fg', timeframe = '1H' } = opts;
458
+
459
+ if (!candles || candles.length < 2 || !candles[0].h) {
460
+ return `{white-fg} (no candle data available)\n{/}`;
461
+ }
462
+
463
+ const n = candles.length;
464
+ const lo = Math.min(...candles.map(c => c.l));
465
+ const hi = Math.max(...candles.map(c => c.h));
466
+ const rng = (hi - lo) === 0 ? (hi || 1) : (hi - lo);
467
+
468
+ const getRow = v => Math.min(height - 1, Math.max(0, Math.round((v - lo) / rng * (height - 1))));
469
+
470
+ const fmtV = v => {
471
+ const a = Math.abs(v);
472
+ if (a >= 10000) return (v / 1000).toFixed(0) + 'k';
473
+ if (a >= 1000) return Math.round(v).toString();
474
+ if (a >= 10) return v.toFixed(0);
475
+ if (a >= 1) return v.toFixed(1);
476
+ if (a >= 0.1) return v.toFixed(2);
477
+ if (a >= 0.001) return v.toFixed(4);
478
+ if (a === 0) return '0';
479
+ return v.toExponential(2);
480
+ };
481
+
482
+ let out = '';
483
+ const totalW = n * colW + (gap > 0 ? n * gap - gap : 0);
484
+
485
+ out += ` {#447766-fg}┌${'─'.repeat(axisW)}┬${'─'.repeat(totalW + 2)}┐{/}\n`;
486
+
487
+ if (opts.priceChanges) {
488
+ const fmtP = (p) => p !== undefined ? (p >= 0 ? `{#00FF88-fg}+${p.toFixed(2)}%{/}` : `{#FF6B6B-fg}${p.toFixed(2)}%{/}`) : '—';
489
+ const pcLines = ` {#88AAAA-fg}5M:{/} ${fmtP(opts.priceChanges.m5)} {#88AAAA-fg}1H:{/} ${fmtP(opts.priceChanges.h1)} {#88AAAA-fg}6H:{/} ${fmtP(opts.priceChanges.h6)} {#88AAAA-fg}24H:{/} ${fmtP(opts.priceChanges.h24)}`;
490
+
491
+ // Calculate padding manually to account for tags correctly
492
+ const pureLen = pcLines.replace(/\{[\w#\/\-]+\}/g, '').length;
493
+ const padR = Math.max(0, (totalW + 2) - pureLen);
494
+
495
+ out += ` {#447766-fg}│${' '.repeat(axisW)}│{/}${pcLines}${' '.repeat(padR)}{#447766-fg}│{/}\n`;
496
+ out += ` {#447766-fg}├${'─'.repeat(axisW)}┼${'─'.repeat(totalW + 2)}┤{/}\n`;
497
+ }
498
+
499
+ for (let rfb = height - 1; rfb >= 0; rfb--) {
500
+ const rowVal = lo + (rfb / (height - 1)) * rng;
501
+ // Only print Y-label every 3 rows
502
+ const yLabel = (rfb % 3 === 0 || rfb === height - 1 || rfb === 0)
503
+ ? fmtV(rowVal).padStart(axisW)
504
+ : ' '.repeat(axisW);
505
+
506
+ out += ` {#447766-fg}│{/}{${axisTag}}${yLabel}{/}{#447766-fg}│ {/}`;
507
+
508
+ for (let i = 0; i < n; i++) {
509
+ if (i > 0 && gap > 0) out += ' '.repeat(gap);
510
+ const c = candles[i];
511
+
512
+ const rHigh = getRow(c.h);
513
+ const rLow = getRow(c.l);
514
+ const rOpen = getRow(c.o);
515
+ const rClose= getRow(c.c);
516
+
517
+ const topB = Math.max(rOpen, rClose);
518
+ const botB = Math.min(rOpen, rClose);
519
+
520
+ const isBull = c.c >= c.o;
521
+ const color = isBull ? '{#00FF88-fg}' : '{#FF6B6B-fg}';
522
+
523
+ let char = ' ';
524
+ if (rfb <= topB && rfb >= botB) {
525
+ char = '█'; // body
526
+ } else if (rfb <= rHigh && rfb >= rLow) {
527
+ char = '│'; // wick
528
+ }
529
+
530
+ out += `${color}${char.repeat(colW)}{/}`;
531
+ }
532
+ out += ` {#447766-fg}│{/}\n`;
533
+ }
534
+
535
+ // Baseline
536
+ out += ` {#447766-fg}├${'─'.repeat(axisW)}┼${'─'.repeat(totalW + 2)}┤{/}\n`;
537
+
538
+ // X labels absolute array
539
+ let xChars = new Array(totalW).fill(' ');
540
+ for (let i = 0; i < n; i++) {
541
+ let anchor = i * (colW + gap);
542
+
543
+ let lbl = '';
544
+ const c = candles[i];
545
+ if (c && c.t) {
546
+ const d = new Date(c.t * 1000);
547
+ if (timeframe === '5M' && i % 6 === 0) {
548
+ lbl = d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
549
+ } else if (timeframe === '1H' && i % 6 === 0) {
550
+ lbl = d.getHours().toString().padStart(2, '0') + ':00';
551
+ } else if (timeframe === '1D' && i % 4 === 0) {
552
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
553
+ lbl = `${months[d.getMonth()]} ${d.getDate()}`;
554
+ }
555
+ }
556
+
557
+ if (lbl) {
558
+ let fits = true;
559
+ for (let j = 0; j < lbl.length; j++) {
560
+ if (anchor + j >= totalW || xChars[anchor + j] !== ' ') fits = false;
561
+ }
562
+ if (fits) {
563
+ for (let j = 0; j < lbl.length; j++) {
564
+ xChars[anchor + j] = lbl[j];
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ const xStr = xChars.join('');
571
+ out += ` {#447766-fg}│${' '.repeat(axisW)}│{/} {${axisTag}}${xStr}{/} {#447766-fg}│{/}\n`;
572
+ out += ` {#447766-fg}└${'─'.repeat(axisW)}┴${'─'.repeat(totalW + 2)}┘{/}\n`;
573
+
574
+ return out;
575
+ }
576
+
577
+
578
+ // ─────────────────────────────────────────────
579
+ // BANNERS
580
+ // ─────────────────────────────────────────────
581
+ function errorBanner(msg) {
582
+ return `\n ${RED_BG('ERROR')} {#FF6B6B-fg}${msg}{/}\n\n {white-fg}Press{/} {#FFD700-fg}R{/} {white-fg}to retry {/}{white-fg}up/down to scroll{/}\n`;
583
+ }
584
+ function loadingBanner(msg) {
585
+ return `\n ${TL_BG('LOADING')} {#00FFFF-fg}${msg || 'Fetching live data...'}{/}\n\n {white-fg}Connecting to Solana mainnet & DexScreener...{/}\n`;
586
+ }
587
+
588
+ // ── Animated Loader ───────────────────────────────────────
589
+ // Usage: const stop = createAnimatedLoader(screen, someBox, 'Fetching...');
590
+ // Call stop() when done to clean up.
591
+ function createAnimatedLoader(screen, box, msg, tips) {
592
+ const SPINNER = ['\u28fe', '\u28f7', '\u28ef', '\u28df', '\u287f', '\u28bf', '\u28fb', '\u28fd'];
593
+ const BARS = ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'];
594
+ const COLORS = ['#00FF88', '#00FFCC', '#00CCFF', '#00AAFF', '#0088FF', '#00AAFF', '#00CCFF', '#00FFCC'];
595
+ const TIPS = tips || [
596
+ 'Connecting to Solana mainnet-beta...',
597
+ 'Querying DexScreener API...',
598
+ 'Syncing live blockchain data...',
599
+ 'Aggregating market intelligence...',
600
+ ];
601
+ let frame = 0;
602
+ let tipIdx = 0;
603
+
604
+ function render() {
605
+ const spin = SPINNER[frame % SPINNER.length];
606
+ const clr = COLORS[frame % COLORS.length];
607
+ const bar = BARS.slice(Math.max(0, (frame % BARS.length) - 5), (frame % BARS.length) + 1).join('');
608
+ const tip = TIPS[tipIdx % TIPS.length];
609
+ const barFull = Array.from({length: 40}, (_, i) => BARS[(frame + i) % BARS.length]).join('');
610
+
611
+ let out = '';
612
+ out += '\n';
613
+ out += ` {${clr}-fg}${barFull}{/}\n`;
614
+ out += '\n';
615
+ out += ` {${clr}-fg}{bold}${spin}{/} {white-fg}{bold}${msg}{/}\n`;
616
+ out += '\n';
617
+ out += ` {white-fg}${tip}{/}\n`;
618
+ out += '\n';
619
+ out += ` {${clr}-fg}${barFull}{/}\n`;
620
+
621
+ if (box && !box.destroyed) {
622
+ box.setContent(out);
623
+ if (screen && !screen.destroyed) screen.render();
624
+ }
625
+ frame++;
626
+ if (frame % 20 === 0) tipIdx++;
627
+ }
628
+
629
+ render();
630
+ const iv = setInterval(render, 100);
631
+ return function stop() { clearInterval(iv); };
632
+ }
633
+
634
+
635
+ // ─────────────────────────────────────────────
636
+ // GUI INPUT — pure blessed prompt for easy pasting
637
+ // ─────────────────────────────────────────────
638
+ function getLineInput(screen, promptText, cb) {
639
+ const form = blessed.form({
640
+ parent: screen, keys: true, left: 'center', top: 'center',
641
+ width: 60, height: 5, style: BOX,
642
+ border: { type: 'line', fg: '#00FFFF' },
643
+ label: ` {#FFD700-fg} INPUT REQUIRED {/} `,
644
+ tags: true
645
+ });
646
+ blessed.text({ parent: form, top: 0, left: 1, content: promptText, style: BOX });
647
+ const input = blessed.textbox({
648
+ parent: form, top: 1, left: 1, right: 1, height: 1,
649
+ keys: true, inputOnFocus: true, style: { bg: '#002222', fg: '#00FF88' }
650
+ });
651
+ input.on('submit', (val) => { form.destroy(); screen.render(); cb((val || '').trim()); });
652
+ input.on('cancel', () => { form.destroy(); screen.render(); cb(''); });
653
+ screen.append(form);
654
+ input.focus();
655
+ input.readInput(); // CRITICAL: Tells blessed to actually accept keyboard strokes
656
+ screen.render();
657
+ }
658
+
659
+ function getSelectionMenu(screen, promptText, options, cb) {
660
+ const form = blessed.form({
661
+ parent: screen, keys: true, left: 'center', top: 'center',
662
+ width: 40, height: options.length + 4, style: BOX,
663
+ border: { type: 'line', fg: '#00FFFF' },
664
+ label: ` {#FFD700-fg} ${promptText} {/} `,
665
+ tags: true
666
+ });
667
+
668
+ const list = blessed.list({
669
+ parent: form, top: 1, left: 1, right: 1, bottom: 1,
670
+ keys: true, interactive: true,
671
+ items: options.map(o => ` ▶ ${o} `),
672
+ style: {
673
+ selected: { bg: '#00FF88', fg: 'black', bold: true },
674
+ item: { fg: 'white', bg: '#001A0D' }
675
+ }
676
+ });
677
+
678
+ list.on('select', (el, selected) => { form.destroy(); screen.render(); cb(options[selected]); });
679
+ list.on('cancel', () => { form.destroy(); screen.render(); cb(''); });
680
+ list.key(['escape', 'q', 'C-c'], () => { form.destroy(); screen.render(); cb(''); });
681
+
682
+ screen.append(form);
683
+ list.focus();
684
+ screen.render();
685
+ }
686
+
687
+ // ══════════════════════════════════════════════════════════
688
+ function startDashboard() {
689
+ let localList;
690
+ const screen = blessed.screen({
691
+ smartCSR: true, title: 'Solana TUI Explorer',
692
+ fullUnicode: true, mouse: true, forceUnicode: true,
693
+ });
694
+
695
+ const root = blessed.box({
696
+ parent: screen, top: 0, left: 0, width: '100%', height: '100%',
697
+ style: BOX,
698
+ });
699
+
700
+ // ─────────────────────────────────────────────
701
+ // TOP BAR — single premium header band
702
+ // ─────────────────────────────────────────────
703
+ const topRow = blessed.box({
704
+ parent: root, top: 0, left: 0, width: '100%', height: 1,
705
+ tags: true,
706
+ style: { bg: '#002E1A', fg: 'white' },
707
+ });
708
+
709
+ // Logo — left
710
+ blessed.text({ parent: topRow, top: 0, left: 0, tags: true,
711
+ style: { bg: '#002E1A' },
712
+ content: ' {#00FF88-fg}{bold}⬡ SOLANA TUI EXPLORER{/} {#336655-fg}│{/} {#668877-fg}On-Chain Developer Inspection TUI{/}' });
713
+
714
+ // Live dot + clock — right
715
+ const clockBox = blessed.text({ parent: topRow, top: 0, right: 0, tags: true,
716
+ style: { bg: '#002E1A' }, content: '' });
717
+ function refreshClock() {
718
+ const now = new Date();
719
+ const dt = now.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: '2-digit' });
720
+ const tm = now.toLocaleTimeString('en-US', { hour12: false });
721
+ clockBox.setContent(`{#00FF88-fg}● LIVE{/} {#99CCBB-fg}${dt} ${tm}{/} {#004D33-bg}{#00FF88-fg}{bold} v1.0 {/} `);
722
+ }
723
+ refreshClock();
724
+ setInterval(refreshClock, 1000);
725
+
726
+ // Ticker — kept as stub so callers don't break
727
+ function refreshTicker() {}
728
+
729
+ // Divider row 1 — bright thin line
730
+ blessed.text({ parent: root, top: 1, left: 0, width: '100%', height: 1, tags: true, style: BOX,
731
+ content: `{#00FF88-fg}${'━'.repeat(400)}{/}` });
732
+
733
+ // ─────────────────────────────────────────────
734
+ // NAV BAR (row 2)
735
+ // ─────────────────────────────────────────────
736
+ const navBar = blessed.box({
737
+ parent: root, top: 2, left: 0, width: '100%', height: 1,
738
+ tags: true, style: { bg: '#001A0D', fg: 'white' },
739
+ });
740
+ blessed.text({ parent: navBar, top: 0, right: 1, tags: true,
741
+ style: { bg: '#001A0D' },
742
+ content: '{#00FF88-fg}● MAINNET{/}' });
743
+ const navContent = blessed.text({ parent: navBar, top: 0, left: 0, tags: true,
744
+ style: { bg: '#001A0D', fg: 'white' }, content: '' });
745
+
746
+ // Divider row 3 — dim thin line
747
+ blessed.text({ parent: root, top: 3, left: 0, width: '100%', height: 1, tags: true, style: BOX,
748
+ content: `{#005533-fg}${'─'.repeat(400)}{/}` });
749
+
750
+ // ─────────────────────────────────────────────
751
+ // RIGHT SIDEBAR (Disabled for full-width layout)
752
+ // ─────────────────────────────────────────────
753
+ const SBW = 0;
754
+ const feedLog = { log: () => {} };
755
+ /*
756
+ const sidebar = blessed.box({
757
+ parent: root, top: 4, right: 0, width: SBW, bottom: 2,
758
+ style: BOX,
759
+ border: { type: 'line', fg: '#00FFFF', left: true, top: false, right: false, bottom: false },
760
+ });
761
+
762
+ const gainBox = blessed.box({
763
+ parent: sidebar, top: 0, left: 0, width: '100%', height: 10,
764
+ label: ' {#00FF88-fg}{bold}TOP GAINERS{/} ', tags: true,
765
+ border: BCYAN, style: BOX,
766
+ });
767
+ function buildGainers() {
768
+ if (!DATA.topGainers.length) { gainBox.setContent(WW(' No gainers presently')); return; }
769
+ let s = '';
770
+ DATA.topGainers.slice(0, 5).forEach(d => {
771
+ const bl = Math.max(0, Math.round(Math.min(d.pct / 40 * 10, 10)));
772
+ s += W(pad(d.symbol, 10)) +
773
+ `{#0EF20A-fg}${'▆'.repeat(bl)}{/}{#c3e3c5-fg}${'▆'.repeat(10 - bl)}{/}` +
774
+ ' ' + G('+' + d.pct.toFixed(1) + '%') + '\n';
775
+ });
776
+ gainBox.setContent(s);
777
+ }
778
+
779
+ const lossBox = blessed.box({
780
+ parent: sidebar, top: 10, left: 0, width: '100%', height: 10,
781
+ label: ' {#FF6B6B-fg}{bold}TOP LOSERS{/} ', tags: true,
782
+ border: BCYAN, style: BOX,
783
+ });
784
+ function buildLosers() {
785
+ if (!DATA.topLosers.length) { lossBox.setContent(WW(' No negative pairs')); return; }
786
+ let s = '';
787
+ DATA.topLosers.slice(0, 5).forEach(d => {
788
+ const bl = Math.max(0, Math.round(Math.min(Math.abs(d.pct) / 15 * 10, 10)));
789
+ s += W(pad(d.symbol, 10)) +
790
+ `{#941234-fg}${'▆'.repeat(bl)}{/}{#c3e3c5-fg}${'▆'.repeat(10 - bl)}{/}` +
791
+ ' ' + DN(d.pct.toFixed(1) + '%') + '\n';
792
+ });
793
+ lossBox.setContent(s);
794
+ }
795
+
796
+ const feedBox = blessed.box({
797
+ parent: sidebar, top: 20, left: 0, width: '100%', bottom: 0,
798
+ label: ' {#FFD700-fg}{bold}LIVE FEED{/} ', tags: true,
799
+ border: BCYAN, style: BOX,
800
+ });
801
+ const feedLog = contrib.log({
802
+ parent: feedBox, top: 0, left: 0, width: '100%-2', height: '100%-2',
803
+ fg: 'white', tags: true, style: BOX,
804
+ });
805
+ */
806
+
807
+ // ─────────────────────────────────────────────
808
+ // MAIN PANE
809
+ // ─────────────────────────────────────────────
810
+ const mainPane = blessed.box({ parent: root, top: 4, left: 0, right: SBW, bottom: 2, style: BOX });
811
+
812
+ let current = 0;
813
+ let activeModal = null;
814
+
815
+ screen.key(['up', 'k'], () => {
816
+ if (activeModal) { activeModal.scroll(-1); screen.render(); return; }
817
+ if (current === 3 && DATA.news.length > 0) {
818
+ newsSelected = Math.max(0, newsSelected - 1);
819
+ buildNewsTab(); screen.render();
820
+ } else if (current === 5 && localList) {
821
+ // Let the focused list handle arrow/j/k keys natively
822
+ } else {
823
+ activeScroll?.scroll(-1); screen.render();
824
+ }
825
+ });
826
+ screen.key(['down', 'j'], () => {
827
+ if (activeModal) { activeModal.scroll(1); screen.render(); return; }
828
+ if (current === 3 && DATA.news.length > 0) {
829
+ newsSelected = Math.min(DATA.news.length - 1, newsSelected + 1);
830
+ buildNewsTab(); screen.render();
831
+ } else if (current === 5 && localList) {
832
+ // Let the focused list handle arrow/j/k keys natively
833
+ } else {
834
+ activeScroll?.scroll(1); screen.render();
835
+ }
836
+ });
837
+ screen.key(['pageup'], () => {
838
+ if (activeModal) { activeModal.scroll(-10); screen.render(); return; }
839
+ activeScroll?.scroll(-10); screen.render();
840
+ });
841
+ screen.key(['pagedown'], () => {
842
+ if (activeModal) { activeModal.scroll(10); screen.render(); return; }
843
+ activeScroll?.scroll(10); screen.render();
844
+ });
845
+ screen.key(['home'], () => {
846
+ if (activeModal) { activeModal.setScrollPerc(0); screen.render(); return; }
847
+ activeScroll?.setScrollPerc(0); screen.render();
848
+ });
849
+ screen.key(['end'], () => {
850
+ if (activeModal) { activeModal.setScrollPerc(100); screen.render(); return; }
851
+ activeScroll?.setScrollPerc(100); screen.render();
852
+ });
853
+ screen.key(['enter', 'return'], () => {
854
+ if (!activeModal && current === 3 && DATA.news.length > 0) {
855
+ showNewsDetail(DATA.news[newsSelected]);
856
+ }
857
+ });
858
+
859
+ function mkScroll(parent, extra = {}) {
860
+ return blessed.box({
861
+ parent, scrollable: true, alwaysScroll: true, mouse: true, tags: true,
862
+ style: { ...BOX, scrollbar: { bg: '#00FFFF' } },
863
+ scrollbar: { ch: '│', style: { fg: '#00FFFF' } },
864
+ ...extra,
865
+ });
866
+ }
867
+ const mkBox = (parent, extra = {}) => blessed.box({ parent, tags: true, style: BOX, ...extra });
868
+
869
+
870
+
871
+
872
+
873
+ // ══════════════════════════════════════════════════════════
874
+ // F3 WALLET
875
+ // ══════════════════════════════════════════════════════════
876
+ const tabWallet = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
877
+ const wltScroll = mkScroll(tabWallet, { top: 0, left: 0, right: 0, bottom: 0 });
878
+ const wltBox = mkBox(wltScroll, { width: '100%-2' });
879
+
880
+ let walletAddr = null, walletLoading = false, walletError = null;
881
+
882
+ function buildWalletTab() {
883
+ let out = '';
884
+ out += OB(' WALLET INSIGHTS') + ` ${WW('Portfolio analytics | Transaction history')} ${walletAddr ? GRY('I=change R=refresh') : C('Press i to enter wallet address')}\n`;
885
+ out += HR(92) + '\n\n';
886
+
887
+ if (!walletAddr) {
888
+ out += `\n ${TL_BG('NO WALLET LOADED')}\n\n`;
889
+ out += ` ${WW('Enter a Solana wallet address to view live balances & transactions.')}\n\n`;
890
+ out += ` ${C('Type')} ${W(' i ')} ${C('on your keyboard to enter a Solana wallet address')}\n\n`;
891
+ out += ` ${LBL('Example: ')}${GRY('7xKkPmVn8RqwZ2jLfBd4uYtX1sCo9HGe5Ap3mNpQ')}\n\n`;
892
+ wltBox.setContent(out); wltBox.height = 16; return;
893
+ }
894
+ if (walletLoading) {
895
+ if (!buildWalletTab._stopLoader) {
896
+ buildWalletTab._stopLoader = createAnimatedLoader(screen, wltBox,
897
+ 'Fetching wallet from Solana RPC...',
898
+ ['Querying token accounts...', 'Resolving token mint addresses...', 'Fetching USD values via DexScreener...', 'Building portfolio summary...']);
899
+ }
900
+ wltBox.height = 14; return;
901
+ }
902
+ if (buildWalletTab._stopLoader) { buildWalletTab._stopLoader(); buildWalletTab._stopLoader = null; }
903
+ if (walletError) {
904
+ out += errorBanner(walletError);
905
+ out += ` ${LBL('Address: ')}${C(walletAddr)}\n\n`;
906
+ out += ` ${WW('Press')} ${C('I')} ${WW('to try a different address')}\n`;
907
+ wltBox.setContent(out); wltBox.height = 16; return;
908
+ }
909
+
910
+ const ww = DATA.wallet;
911
+ if (!ww) return;
912
+
913
+ out += ` ${LBL('WALLET:')} ${C(ww.fullAddress)} ${GRY('(Mainnet RPC)')}\n`;
914
+ out += ` ${LBL('ASSETS:')} ${W(ww.holdings.length + ' tokens')} ${GRY('│')} ${LBL('IDENT:')} ${W(ww.address)}\n`;
915
+ out += ` ${LBL('PORTFOLIO:')} {#00FF88-fg}{bold}$${ww.totalValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{/}\n`;
916
+ out += HR(92) + '\n\n';
917
+
918
+ // Holdings
919
+ out += ` ${TL_BG(' HOLDINGS ')} ${WW(ww.holdings.length + ' assets via Solana RPC')}\n\n`;
920
+ out += ' ' + LBL(pad('TOKEN', 10)) + LBL(pad('AMOUNT', 22)) + LBL(pad('USD VALUE', 18)) + LBL(pad('ALLOC%', 8)) + LBL('WEIGHT') + '\n';
921
+ out += HR(78) + '\n';
922
+ ww.holdings.forEach(h => {
923
+ const barF = Math.round((h.pct || 0) / 100 * 14);
924
+ const barE = Math.max(0, 14 - barF);
925
+ const val = h.value > 0
926
+ ? `{#00FF88-fg}{bold}$${h.value.toLocaleString('en-US', { maximumFractionDigits: 2 })}{/}`
927
+ : GRY('price unavailable ');
928
+ const vLen = h.value > 0 ? ('$' + h.value.toFixed(2)).length : 17;
929
+ out += ' ' + W(pad(h.token, 10)) +
930
+ WW(pad(String(h.amount), 22)) +
931
+ val + ' '.repeat(Math.max(1, 18 - vLen)) +
932
+ Y(pad((h.pct || 0).toFixed(1) + '%', 8)) +
933
+ `{#00FF88-fg}${'█'.repeat(barF)}{/}{#114422-fg}${'░'.repeat(barE)}{/}\n`;
934
+ });
935
+ out += HR(92) + '\n\n';
936
+
937
+ // ── FairScale Reputation & Trust Section ──
938
+ if (ww.fairScale && !ww.fairScale.error) {
939
+ const fs = ww.fairScale;
940
+ const score = Math.round(fs.fairscore || 0);
941
+ const tier = (fs.tier || 'bronze').toLowerCase();
942
+
943
+ const TIER_CONFIG = {
944
+ diamond: { clr: '{#E5E4E2-fg}', bg: '{#333333-bg}', sym: '💎' },
945
+ platinum: { clr: '{#7FFFD4-fg}', bg: '{#002222-bg}', sym: '💠' },
946
+ gold: { clr: '{#FFD700-fg}', bg: '{#222200-bg}', sym: '📀' },
947
+ silver: { clr: '{#C0C0C0-fg}', bg: '', sym: '💿' },
948
+ bronze: { clr: '{#CD7F32-fg}', bg: '{#111111-bg}', sym: '🔘' }
949
+ };
950
+ const cfg = TIER_CONFIG[tier] || TIER_CONFIG.bronze;
951
+
952
+ out += ` ${TL_BG(' REPUTATION ')} ${cfg.bg}${cfg.clr}{bold} ${cfg.sym} ${tier.toUpperCase()}{/}{/} ${WW('│ FairScale Score:')} ${cfg.clr}{bold}${score}/100{/} ${WW('│ Humanity:')} ${fs.verified_human ? G('VERIFIED') : Y('PROBABLE')}\n`;
953
+
954
+ // Achievements
955
+ if (fs.badges && fs.badges.length > 0) {
956
+ const icons = {
957
+ 'lst_staker': '🔒', 'sol_maxi': '💰', 'no_dumper': '💎', 'diamond_hands': '💎',
958
+ 'net_accumulator': '📈', 'active_trader': '🔥', 'diversified': '🌀',
959
+ 'social_connected': '🔗', 'active_tweeter': '🔔', 'content_creator': '🎨', 'positive_vibes': '😁'
960
+ };
961
+
962
+ out += ` ${LBL('Achievements:')}\n `;
963
+ let rowLen = 0;
964
+ fs.badges.slice(0, 8).forEach(b => {
965
+ const icon = icons[b.id] || '🏅';
966
+ const bClr = b.tier === 'gold' ? '{#FFD700-fg}' : b.tier === 'silver' ? '{#C0C0C0-fg}' : '{#CD7F32-fg}';
967
+ const badgeStr = `{#111111-bg}${bClr}${icon} ${b.label}{/}`;
968
+
969
+ // Safer wrap logic based on character count (ignoring tags roughly)
970
+ if (rowLen + b.label.length > 60) { out += '\n '; rowLen = 0; }
971
+ out += badgeStr + ' ';
972
+ rowLen += b.label.length + 6;
973
+ });
974
+ out += '\n';
975
+ }
976
+
977
+ // Action Item
978
+ if (fs.actions && fs.actions.length > 0) {
979
+ const action = fs.actions[0];
980
+ const actPrio = action.priority === 'high' ? '{#FF6B6B-fg}' : '{#FFD700-fg}';
981
+ const label = action.label.substring(0, 30);
982
+ const desc = action.description.substring(0, 50);
983
+ out += ` ${actPrio}●{/} ${W('TRUST SIGNAL:')} ${W(label)} — ${GRY(desc)}\n`;
984
+ }
985
+ out += ` ${HR(92)}\n\n`;
986
+ } else if (ww.fairScale?.error) {
987
+ out += ` ${GRY('FairScale reputation data currently unavailable for this wallet.')}\n\n`;
988
+ }
989
+
990
+ // Transactions
991
+ out += ` ${TL_BG(' RECENT TRANSACTIONS ')} ${WW('Last 5 via Solana RPC')}\n\n`;
992
+ out += ' ' + LBL(pad('TIME', 12)) + LBL(pad('TYPE', 10)) + LBL(pad('STATUS', 14)) + LBL('SIGNATURE') + '\n';
993
+ out += HR(66) + '\n';
994
+ (ww.recentTxns || []).forEach(tx => {
995
+ const st = tx.status === 'CONFIRMED' ? GRN_BG('CONFIRMED') : RED_BG('FAILED');
996
+ out += ' ' + WW(pad(tx.time, 12)) + C(pad(tx.type, 10)) + st + ' ' + GRY(tx.sig) + '\n';
997
+ });
998
+ if (!ww.recentTxns?.length) out += ` ${WW('No recent transactions found.')}\n`;
999
+ out += `\n ${GRY('Full history: solscan.io/account/' + ww.fullAddress)}\n`;
1000
+ wltBox.setContent(out);
1001
+ wltBox.height = Math.max(32, 24 + (ww.holdings?.length || 0) + (ww.recentTxns?.length || 0) + 8);
1002
+ }
1003
+
1004
+ async function loadWallet(address) {
1005
+ walletAddr = address; walletLoading = true; walletError = null;
1006
+ buildWalletTab(); screen.render();
1007
+ try {
1008
+ await loadWalletData(address);
1009
+ walletLoading = false;
1010
+ } catch (e) {
1011
+ walletLoading = false;
1012
+ walletError = e.message.substring(0, 80);
1013
+ }
1014
+ buildWalletTab(); wltScroll.setScrollPerc(0); screen.render();
1015
+ }
1016
+
1017
+ // ══════════════════════════════════════════════════════════
1018
+ // F4 TOKEN
1019
+ // ══════════════════════════════════════════════════════════
1020
+ const tabToken = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
1021
+ const tokScroll = mkScroll(tabToken, { top: 0, left: 0, width: '100%', bottom: 0 });
1022
+ const tokBox = mkBox(tokScroll, { width: '100%-2' });
1023
+
1024
+ let tokenTimeframe = '1H';
1025
+ let tokenQuery = null, tokenLoading = false, tokenError = null;
1026
+
1027
+ function buildTokenTab() {
1028
+ const tk = DATA.token;
1029
+ let out = '';
1030
+ out += OB(' TOKEN ANALYTICS') + ` ${WW(tk ? tk.symbol + ' / ' + tk.name : 'No Token Selected')} ${tokenQuery ? GRY('i=search r=refresh t=change timeframe') : C('Press I to enter token')}\n`;
1031
+ out += HR(92) + '\n';
1032
+
1033
+ if (!tokenQuery) {
1034
+ out += `\n ${TL_BG('NO TOKEN SELECTED')}\n\n`;
1035
+ out += ` ${WW('Enter a token mint address or symbol.')}\n\n`;
1036
+ out += ` ${C('Type')} ${W(' i ')} ${C('on your keyboard to search a token')}\n\n`;
1037
+ out += ` ${LBL('Symbols:')} ${G('BONK')} ${G('WIF')} ${G('JUP')} ${G('SOL')} ${G('RAY')}\n`;
1038
+ out += ` ${LBL('Mint: ')} ${GRY('DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263')}\n\n`;
1039
+
1040
+ if (DATA.tokenSocials && DATA.tokenSocials.length > 0) {
1041
+ out += `\n ${TL_BG('GLOBAL SOLANA X/TWITTER FEED')} ${WW('Auto-updating global Solana stream')}\n\n`;
1042
+ DATA.tokenSocials.forEach(s => {
1043
+ const timeStr = s.date.toLocaleTimeString('en-US', {hour:'2-digit', minute:'2-digit'});
1044
+ out += ` {#1DA1F2-fg}[${timeStr}] {/} ` + W(s.title.substring(0, 85)) + `\n`;
1045
+ out += ` ${GRY('╰─ Source: ' + s.source)}\n\n`;
1046
+ });
1047
+ out += HR(68) + '\n';
1048
+ }
1049
+
1050
+ tokBox.setContent(out);
1051
+ tokBox.height = out.split('\n').length + 2;
1052
+ return;
1053
+ }
1054
+ if (tokenLoading) {
1055
+ if (!buildTokenTab._stopLoader) {
1056
+ buildTokenTab._stopLoader = createAnimatedLoader(screen, tokBox,
1057
+ 'Fetching token data from DexScreener...',
1058
+ ['Looking up token metadata...', 'Fetching liquidity pools...', 'Pulling price history data...', 'Calculating risk profile...']);
1059
+ }
1060
+ tokBox.height = 14; return;
1061
+ }
1062
+ if (buildTokenTab._stopLoader) { buildTokenTab._stopLoader(); buildTokenTab._stopLoader = null; }
1063
+ if (tokenError) {
1064
+ out += errorBanner(tokenError);
1065
+ out += ` ${LBL('Query: ')}${C(tokenQuery)}\n\n`;
1066
+ out += ` ${WW('Press')} ${C('I')} ${WW('to try a different token')}\n`;
1067
+ tokBox.setContent(out); tokBox.height = 16; return;
1068
+ }
1069
+ if (!tk) return;
1070
+
1071
+ const W1=8, W2=12, W3=9, W4=10, W5=10, W6=11, W7=10, W8=9;
1072
+
1073
+ out += `\n ${TL_BG('TOKEN OVERVIEW')} ${WW(tk.name + ' Mint: ' + (tk.shortMint || tk.mint || '—'))}\n`;
1074
+
1075
+ out += ' {#447766-fg}┌' + '─'.repeat(W1+1) + '┬' + '─'.repeat(W2+1) + '┬' + '─'.repeat(W3+1) + '┬' + '─'.repeat(W4+1) + '┬' + '─'.repeat(W5+1) + '┬' + '─'.repeat(W6+1) + '┬' + '─'.repeat(W7+1) + '┬' + '─'.repeat(W8+1) + '┐{/}\n';
1076
+ out += ' {#447766-fg}│{/} ' + LBL(pad('SYMBOL', W1)) + '{#447766-fg}│{/} ' + LBL(pad('PRICE', W2)) + '{#447766-fg}│{/} ' + LBL(pad('24H %', W3))
1077
+ + '{#447766-fg}│{/} ' + LBL(pad('MKT CAP', W4)) + '{#447766-fg}│{/} ' + LBL(pad('FDV', W5)) + '{#447766-fg}│{/} ' + LBL(pad('LIQUIDITY', W6)) + '{#447766-fg}│{/} ' + LBL(pad('VOL 24H', W7)) + '{#447766-fg}│{/} ' + LBL(pad('HOLDERS', W8)) + '{#447766-fg}│{/}\n';
1078
+ out += ' {#447766-fg}├' + '─'.repeat(W1+1) + '┼' + '─'.repeat(W2+1) + '┼' + '─'.repeat(W3+1) + '┼' + '─'.repeat(W4+1) + '┼' + '─'.repeat(W5+1) + '┼' + '─'.repeat(W6+1) + '┼' + '─'.repeat(W7+1) + '┼' + '─'.repeat(W8+1) + '┤{/}\n';
1079
+
1080
+ const pcStr = tk.priceChange24h >= 0
1081
+ ? `{#00FF88-fg}` + pad('+' + tk.priceChange24h.toFixed(2) + '%', W3) + `{/}`
1082
+ : `{#FF6B6B-fg}` + pad(tk.priceChange24h.toFixed(2) + '%', W3) + `{/}`;
1083
+
1084
+ out += ' {#447766-fg}│{/} ' + W(pad(tk.symbol.substring(0, W1), W1))
1085
+ + '{#447766-fg}│{/} ' + `{#00FF88-fg}{bold}${pad(fmtPrice(tk.price), W2)}{/}`
1086
+ + '{#447766-fg}│{/} ' + pcStr
1087
+ + '{#447766-fg}│{/} ' + W(pad(tk.marketCap, W4))
1088
+ + '{#447766-fg}│{/} ' + W(pad(tk.fdv || tk.marketCap, W5))
1089
+ + '{#447766-fg}│{/} ' + WW(pad(tk.liquidity, W6))
1090
+ + '{#447766-fg}│{/} ' + C(pad(tk.volume24h, W7))
1091
+ + '{#447766-fg}│{/} ' + W(pad(String(tk.holders), W8)) + '{#447766-fg}│{/}\n';
1092
+ out += ' {#447766-fg}└' + '─'.repeat(W1+1) + '┴' + '─'.repeat(W2+1) + '┴' + '─'.repeat(W3+1) + '┴' + '─'.repeat(W4+1) + '┴' + '─'.repeat(W5+1) + '┴' + '─'.repeat(W6+1) + '┴' + '─'.repeat(W7+1) + '┴' + '─'.repeat(W8+1) + '┘{/}\n\n';
1093
+
1094
+ let socCards = [];
1095
+ if (tk.socialInfo?.websites?.length) {
1096
+ socCards.push(`{#002222-bg}{#00FFFF-fg} Website {/} ${W(tk.socialInfo.websites[0].url.replace('https://',''))}`);
1097
+ }
1098
+ if (tk.socialInfo?.socials?.length) {
1099
+ tk.socialInfo.socials.forEach(s => {
1100
+ let type = s.type.toUpperCase();
1101
+ let color = type === 'TWITTER' ? '{#1DA1F2-fg}' : type === 'TELEGRAM' ? '{#0088cc-fg}' : '{#00FFFF-fg}';
1102
+ socCards.push(`{#001111-bg}${color} ${type} {/} ${W(s.url.replace('https://','').replace('http://',''))}`);
1103
+ });
1104
+ }
1105
+ if (socCards.length > 0) {
1106
+ out += ` ${TL_BG('TOKEN METADATA')} ${WW('Verified Web & Social Links')}\n`;
1107
+ out += ' ' + socCards.join(' ') + '\n\n';
1108
+ }
1109
+
1110
+ if (tk.historicalCandles && tk.historicalCandles.length > 0) {
1111
+ const maxLen = 42;
1112
+ const slicedCandles = tk.historicalCandles.slice(-maxLen);
1113
+
1114
+ out += ` ${TL_BG('PRICE HISTORY')} ${WW(`Chart Timeframe: [${tokenTimeframe}] (press t to change)`)}\n`;
1115
+
1116
+ const chartStr = candleChart(slicedCandles, {
1117
+ height: 14, colW: 1, gap: 1, axisW: 9,
1118
+ axisTag: '#00AAAA-fg',
1119
+ timeframe: tokenTimeframe,
1120
+ priceChanges: tk.extPriceChange
1121
+ });
1122
+ out += chartStr + '\n';
1123
+ } else {
1124
+ out += ` {#FF6B6B-fg}(No chart data available for this timeframe. Automatically syncing...){/}\n\n`;
1125
+ }
1126
+
1127
+ const buys = tk.txns?.h24?.buys || 0;
1128
+ const sells = tk.txns?.h24?.sells || 0;
1129
+ const txns24 = buys + sells || 1;
1130
+
1131
+ const rawVol = tk.extVolume?.h24 || 0;
1132
+ const bVol = (buys / txns24) * rawVol;
1133
+ const sVol = (sells / txns24) * rawVol;
1134
+
1135
+ const makers = Math.floor(txns24 * 0.045) || 1;
1136
+ const buyers = Math.floor(makers * (buys / txns24));
1137
+ const sellers = makers - buyers;
1138
+
1139
+ const fmtNum = (v) => Math.round(v).toLocaleString();
1140
+ const fmtVol2 = (v) => {
1141
+ if (!v) return '0';
1142
+ if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B';
1143
+ if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M';
1144
+ if (v >= 1000) return (v / 1000).toFixed(0) + 'K';
1145
+ return v.toFixed(2);
1146
+ };
1147
+
1148
+ const drawSplitBar = (v1, v2, w) => {
1149
+ const top = v1 + v2 || 1;
1150
+ const L1 = Math.max(1, Math.round((v1/top) * (w - 1)));
1151
+ const L2 = Math.max(1, (w - 1) - L1);
1152
+ return `{#00FF88-fg}${'▄'.repeat(L1)}{/} {#FF6B6B-fg}${'▄'.repeat(L2)}{/}`;
1153
+ };
1154
+
1155
+ out += ` ${TL_BG('24H TRANSPARENCY & FLOW')} ${WW('Derived on-chain DEX flow')}\n`;
1156
+
1157
+ const cw1 = 16, cw2 = 18;
1158
+ const barW = 32;
1159
+ const rw = barW + 8; // Right column width
1160
+
1161
+ const padStr = (s, w) => s + ' '.repeat(Math.max(0, w - s.replace(/\{[\w#\/\-]+\}/g, '').length));
1162
+
1163
+ const right1a = padStr(GRY(pad('BUYS', cw2)) + GRY('SELLS'), rw);
1164
+ const right1b = padStr(G(pad(fmtNum(buys), cw2)) + DN(fmtNum(sells)), rw);
1165
+ const right1c = padStr(drawSplitBar(buys, sells, barW), rw);
1166
+
1167
+ const right2a = padStr(GRY(pad('BUY VOL', cw2)) + GRY('SELL VOL'), rw);
1168
+ const right2b = padStr(G(pad('$' + fmtVol2(bVol), cw2)) + DN('$' + fmtVol2(sVol)), rw);
1169
+ const right2c = padStr(drawSplitBar(bVol, sVol, barW), rw);
1170
+
1171
+ const right3a = padStr(GRY(pad('BUYERS', cw2)) + GRY('SELLERS'), rw);
1172
+ const right3b = padStr(G(pad(fmtNum(buyers), cw2)) + DN(fmtNum(sellers)), rw);
1173
+ const right3c = padStr(drawSplitBar(buyers, sellers, barW), rw);
1174
+
1175
+ const row1 = ' {#447766-fg}│{/} ' + LBL(pad('TXNS', cw1)) + '{#447766-fg}│{/} ' + right1a + '{#447766-fg}│{/}\n' +
1176
+ ' {#447766-fg}│{/} ' + W(pad(fmtNum(txns24), cw1)) + '{#447766-fg}│{/} ' + right1b + '{#447766-fg}│{/}\n' +
1177
+ ' {#447766-fg}│{/} ' + ' '.repeat(cw1) + '{#447766-fg}│{/} ' + right1c + '{#447766-fg}│{/}\n';
1178
+
1179
+ const row2 = ' {#447766-fg}│{/} ' + LBL(pad('VOLUME', cw1)) + '{#447766-fg}│{/} ' + right2a + '{#447766-fg}│{/}\n' +
1180
+ ' {#447766-fg}│{/} ' + W(pad('$' + fmtVol2(rawVol), cw1)) + '{#447766-fg}│{/} ' + right2b + '{#447766-fg}│{/}\n' +
1181
+ ' {#447766-fg}│{/} ' + ' '.repeat(cw1) + '{#447766-fg}│{/} ' + right2c + '{#447766-fg}│{/}\n';
1182
+
1183
+ const row3 = ' {#447766-fg}│{/} ' + LBL(pad('MAKERS', cw1)) + '{#447766-fg}│{/} ' + right3a + '{#447766-fg}│{/}\n' +
1184
+ ' {#447766-fg}│{/} ' + W(pad(fmtNum(makers), cw1)) + '{#447766-fg}│{/} ' + right3b + '{#447766-fg}│{/}\n' +
1185
+ ' {#447766-fg}│{/} ' + ' '.repeat(cw1) + '{#447766-fg}│{/} ' + right3c + '{#447766-fg}│{/}\n';
1186
+
1187
+ out += ` {#447766-fg}┌${'─'.repeat(cw1+2)}┬${'─'.repeat(rw + 2)}┐{/}\n`;
1188
+ out += row1;
1189
+ out += ` {#447766-fg}├${'─'.repeat(cw1+2)}┼${'─'.repeat(rw + 2)}┤{/}\n`;
1190
+ out += row2;
1191
+ out += ` {#447766-fg}├${'─'.repeat(cw1+2)}┼${'─'.repeat(rw + 2)}┤{/}\n`;
1192
+ out += row3;
1193
+ out += ` {#447766-fg}└${'─'.repeat(cw1+2)}┴${'─'.repeat(rw + 2)}┘{/}\n\n`;
1194
+
1195
+ out += ` ${TL_BG('RISK SIGNALS')} ${WW('Derived from on-chain DEX data')}\n\n`;
1196
+ out += ' ' + LBL(pad('LEVEL', 12)) + LBL(pad('SIGNAL', 28)) + LBL('DETAIL') + '\n';
1197
+ out += HR(68) + '\n';
1198
+ (tk.riskSignals || []).forEach(rr => {
1199
+ const badge =
1200
+ rr.level === 'HIGH' ? RED_BG('HIGH ') :
1201
+ rr.level === 'MEDIUM' ? YEL_BG('MEDIUM') : GRN_BG('LOW ');
1202
+ out += ' ' + badge + ' ' + W(pad(rr.label, 28)) + WW(rr.detail) + '\n';
1203
+ });
1204
+ out += HR(92) + '\n';
1205
+
1206
+ // ─────────────────────── RUGCHECK SECTION ───────────────────────
1207
+ if (tk.rugCheck) {
1208
+ const rc = tk.rugCheck;
1209
+ const isGood = rc.riskLevel === 'GOOD';
1210
+ const isDanger = rc.riskLevel === 'DANGER';
1211
+ const isWarn = rc.riskLevel === 'WARN';
1212
+
1213
+ // Badge colors
1214
+ const badgeBg = isDanger ? '{#FF0033-bg}{white-fg}' : isWarn ? '{#FF8800-bg}{black-fg}' : '{#00AA44-bg}{white-fg}';
1215
+ const badgeText = isDanger ? ' ⚠ DANGER ' : isWarn ? ' ⚠ WARN ' : ' ✓ GOOD ';
1216
+ const badgeEnd = '{/}';
1217
+
1218
+ const scoreColor = isDanger ? '{#FF6B6B-fg}' : isWarn ? '{#FFD700-fg}' : '{#00FF88-fg}';
1219
+ const lpColor = rc.lpLockedPct >= 80 ? '{#00FF88-fg}' : rc.lpLockedPct >= 40 ? '{#FFD700-fg}' : '{#FF6B6B-fg}';
1220
+
1221
+ out += `\n ${TL_BG('RUGCHECK ANALYSIS')} ${WW('Token security audit via RugCheck.xyz')}\n\n`;
1222
+
1223
+ // Score badge row
1224
+ out += ` ${badgeBg}{bold}${badgeText}{/}${badgeEnd} `;
1225
+ out += `${scoreColor}Rug Score: ${rc.normalised} / 100{/} `;
1226
+ const safeLpPct = Math.min(100, Math.max(0, rc.lpLockedPct));
1227
+ out += `${lpColor}LP Locked: ${safeLpPct.toFixed(2)}%{/}\n`;
1228
+
1229
+ const mintStr = rc.hasMint ? RED_BG('YES') : GRN_BG(' NO');
1230
+ const freezeStr = rc.hasFreeze ? RED_BG('YES') : GRN_BG(' NO');
1231
+
1232
+ const holdPct = rc.creatorBalance > 0 ? (rc.creatorBalance / (tk.rawSupply || 1)) * 100 : 0;
1233
+ const creatorStr = holdPct < 0.01 ? GRN_BG(' SOLD 100% ') : YEL_BG(` HOLDING ${holdPct.toFixed(1)}% `);
1234
+
1235
+ out += `\n ${LBL('Mintable:')} ${mintStr} ${LBL('Freezable:')} ${freezeStr} ${LBL('Creator Balance:')} ${creatorStr}\n\n`;
1236
+
1237
+ if (rc.risks.length === 0) {
1238
+ out += ` {#00FF88-fg}✓ No risk factors detected{/}\n`;
1239
+ } else {
1240
+ // Risk flags table
1241
+ const rw1 = 10, rw2 = 28, rw3 = 36;
1242
+ out += ' {#447766-fg}┌' + '─'.repeat(rw1+1) + '┬' + '─'.repeat(rw2+1) + '┬' + '─'.repeat(rw3+1) + '┐{/}\n';
1243
+ out += ' {#447766-fg}│{/} ' + LBL(pad('SEVERITY', rw1)) + '{#447766-fg}│{/} ' + LBL(pad('RISK NAME', rw2)) + '{#447766-fg}│{/} ' + LBL(pad('DESCRIPTION', rw3)) + '{#447766-fg}│{/}\n';
1244
+ out += ' {#447766-fg}├' + '─'.repeat(rw1+1) + '┼' + '─'.repeat(rw2+1) + '┼' + '─'.repeat(rw3+1) + '┤{/}\n';
1245
+ rc.risks.forEach(r => {
1246
+ const lvl = (r.level || '').toLowerCase();
1247
+ const badge =
1248
+ lvl === 'danger' ? RED_BG('DANGER') :
1249
+ lvl === 'warn' ? YEL_BG(' WARN ') :
1250
+ GRN_BG(' INFO ');
1251
+ const nameStr = pad((r.name || '').substring(0, rw2), rw2);
1252
+ const descStr = pad((r.description || '').substring(0, rw3), rw3);
1253
+ const nameColor = lvl === 'danger' ? '{#FF6B6B-fg}' : lvl === 'warn' ? '{#FFD700-fg}' : '{#00FF88-fg}';
1254
+ out += ' {#447766-fg}│{/} ' + badge + ' {#447766-fg}│{/} ' + `${nameColor}${nameStr}{/}` + ' {#447766-fg}│{/} ' + GRY(descStr) + ' {#447766-fg}│{/}\n';
1255
+ });
1256
+ out += ' {#447766-fg}└' + '─'.repeat(rw1+1) + '┴' + '─'.repeat(rw2+1) + '┴' + '─'.repeat(rw3+1) + '┘{/}\n';
1257
+ }
1258
+ out += '\n';
1259
+ }
1260
+
1261
+ out += `\n ${TL_BG('DEX LIQUIDITY POOLS')} ${WW((tk.dexPools?.length || 0) + ' pairs via DexScreener')}\n\n`;
1262
+ out += ' ' + LBL(pad('DEX', 16)) + LBL(pad('PAIR', 20)) + LBL(pad('LIQUIDITY', 18)) + LBL('24H VOLUME') + '\n';
1263
+ out += HR(68) + '\n';
1264
+ (tk.dexPools || []).forEach(p => {
1265
+ out += ' ' + W(pad(p.dex, 16)) + WW(pad(p.pair, 20)) + G(pad(p.tvl, 18)) + C(p.volume) + '\n';
1266
+ });
1267
+
1268
+ if (tk.topHolders && tk.topHolders.length > 0) {
1269
+ const sumPct = tk.topHolders.reduce((s, h) => s + h.pct, 0);
1270
+ out += `\n ${TL_BG('TOP 10 HOLDERS')} ${WW(`Top 10 own ${sumPct.toFixed(2)}%`)}\n\n`;
1271
+ out += ' ' + LBL(pad('RANK', 8)) + LBL(pad('ADDRESS', 18)) + LBL(pad('% SHARE', 14)) + LBL(pad('AMOUNT', 16)) + LBL('VALUE (USD)') + '\n';
1272
+ out += HR(68) + '\n';
1273
+ tk.topHolders.forEach(h => {
1274
+ out += ' ' + GRY(pad('#' + h.rank, 8)) + W(pad(h.address, 18)) + G(pad(h.pct.toFixed(2) + '%', 14)) + WW(pad(fmtNum(h.amount), 16)) + C('$' + fmtVol2(h.value)) + '\n';
1275
+ });
1276
+ out += HR(68) + '\n';
1277
+ }
1278
+
1279
+ if (DATA.tokenSocials && DATA.tokenSocials.length > 0) {
1280
+ out += `\n ${TL_BG('X / TWITTER FEED')} ${WW('Aggregated via decentralized RSS')}\n\n`;
1281
+ DATA.tokenSocials.forEach(s => {
1282
+ const timeStr = s.date.toLocaleTimeString('en-US', {hour:'2-digit', minute:'2-digit'});
1283
+ out += ` {#1DA1F2-fg}[${timeStr}] {/} ` + W(s.title.substring(0, 85)) + `\n`;
1284
+ out += ` ${GRY('╰─ Source: ' + s.source)}\n\n`;
1285
+ });
1286
+ out += HR(68) + '\n';
1287
+ }
1288
+
1289
+ out += `\n ${GRY('Details: dexscreener.com/solana/' + tk.mint)}\n`;
1290
+ tokBox.setContent(out);
1291
+ tokBox.height = out.split('\n').length + 2;
1292
+ }
1293
+
1294
+ async function loadToken(mintOrSymbol, tf) {
1295
+ if (mintOrSymbol) tokenQuery = mintOrSymbol;
1296
+ if (tf) tokenTimeframe = tf;
1297
+ tokenLoading = true; tokenError = null;
1298
+ buildTokenTab(); screen.render();
1299
+ try {
1300
+ await loadTokenData(tokenQuery, tokenTimeframe);
1301
+ tokenLoading = false;
1302
+ } catch (e) {
1303
+ tokenLoading = false;
1304
+ tokenError = e.message.substring(0, 80);
1305
+ }
1306
+ buildTokenTab(); tokScroll.setScrollPerc(0); screen.render();
1307
+ }
1308
+
1309
+
1310
+
1311
+ // ══════════════════════════════════════════════════════════
1312
+ // F8 NETWORK
1313
+ // ══════════════════════════════════════════════════════════
1314
+ const tabNetwork = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
1315
+ const netScroll = mkScroll(tabNetwork, { top: 0, left: 0, right: 0, bottom: 0 });
1316
+ const netBox = mkBox(netScroll, { width: '100%-2' });
1317
+
1318
+ // netHasData = true once the first successful network+geo load completes.
1319
+ // After that, we NEVER show the spinner — old data stays visible during refresh.
1320
+ let netLoading = true, netError = null, netHasData = false;
1321
+
1322
+ function buildNetworkTab(fromHeartbeat = false) {
1323
+ let out = '';
1324
+ out += OB(' NETWORK STATS') + ' ' + WW('Solana blockchain \u2502 Real-time RPC data') + '\n';
1325
+ out += HR(88) + '\n\n';
1326
+
1327
+ // Only show spinner when there is truly no data yet (first boot)
1328
+ if (netLoading && !netHasData && !fromHeartbeat) {
1329
+ if (!buildNetworkTab._stopLoader) {
1330
+ buildNetworkTab._stopLoader = createAnimatedLoader(screen, netBox,
1331
+ 'Fetching epoch, TPS & validators...',
1332
+ ['Querying Solana RPC for epoch info...', 'Sampling recent TPS data...', 'Fetching block time samples...', 'Loading validator gossip data...', 'Compiling supply metrics...']);
1333
+ }
1334
+ netBox.height = 12; return;
1335
+ }
1336
+ if (buildNetworkTab._stopLoader) { buildNetworkTab._stopLoader(); buildNetworkTab._stopLoader = null; }
1337
+ // Show errors only if we have no prior data to display
1338
+ if (netError && !netHasData && !fromHeartbeat) { out += errorBanner(netError); netBox.setContent(out); netBox.height = 12; return; }
1339
+
1340
+ // ── VALIDATOR WORLD MAP + LEADER RIBBON ──
1341
+ out += ' ' + TL_BG(' VALIDATOR DISTRIBUTION - WORLD MAP ') + '\n';
1342
+ out += ' ' + GRY('Node positions via gossip IP geolocation (getClusterNodes + ip-api.com)') + '\n\n';
1343
+ if (validatorGeoLoading) {
1344
+ out += ' ' + Y('Geolocating validators via IP...') + ' ' + GRY('(~5s)') + '\n\n';
1345
+ } else if (validatorGeoData === null) {
1346
+ out += ' ' + GRY('Loading on first open...') + '\n\n';
1347
+ } else if (validatorGeoData.totalNodes === 0) {
1348
+ out += ' ' + DN('Could not fetch validator node list.') + '\n\n';
1349
+ } else {
1350
+ const geo = validatorGeoData;
1351
+ const sampleSize = geo.sampleSize || 135;
1352
+ const resolvedPct = geo.geoPoints.length > 0 ? ((geo.geoPoints.length / sampleSize) * 100).toFixed(0) : 0;
1353
+
1354
+ out += ' ' + C('Status') + ' ' + W('LIVE RADAR ACTIVE') + '\n\n';
1355
+ // World Radar Map (includes Leader Sidebar)
1356
+ out += buildAsciiWorldMap(geo.geoPoints, geo.leaders);
1357
+ out += '\n';
1358
+
1359
+ // TOP REGIONS
1360
+ out += ' ' + TL_BG(' TOP REGIONS ') + '\n\n';
1361
+ const cols = 3, padN = 22;
1362
+ const cRows = Math.ceil((geo.countryList || []).length / cols);
1363
+ for (let r = 0; r < cRows; r++) {
1364
+ let row = ' ';
1365
+ for (let c = 0; c < cols; c++) {
1366
+ const entry = (geo.countryList || [])[r + c * cRows];
1367
+ if (entry) {
1368
+ const pct = ((entry[1] / geo.geoPoints.length) * 100).toFixed(0);
1369
+ row += C(pad(entry[0], padN)) + W(pad(String(entry[1]) + ' nodes', 12)) + progressBar(parseFloat(pct), 14) + ' ' + Y(pct + '%') + ' ';
1370
+ }
1371
+ }
1372
+ out += row + '\n';
1373
+ }
1374
+ out += '\n';
1375
+ }
1376
+ out += HR(88) + '\n\n';
1377
+
1378
+ // ── STATIC STATS (only rebuilds on full refresh) ──
1379
+ const ns = DATA.networkStats || {};
1380
+ const ep = ns.epoch || {};
1381
+ const tps = ns.tps || { current: 0, average: 0, maximum: 0, minimum: 0, history: [] };
1382
+ const bt = ns.blocktime || { current: 0, average: 0, maximum: 0, minimum: 0, history: [] };
1383
+ const sp = ns.supply || { circulating: 0, staked: 0, total: 0, circulatingPct: 0, stakedPct: 0, epoch: 0, inflationRate: 0, stakingApy: 0 };
1384
+ const sd = ns.stakeData || { totalStaked: '?', filterApy: '?', updated: '?' };
1385
+ const validators = ns.validators || [];
1386
+ const T = 20;
1387
+
1388
+ // ── EPOCH ──
1389
+ if (ep && ep.current !== undefined) {
1390
+ out += ' ' + TL_BG('EPOCH') + ' ' + W('Epoch ' + ep.current) + ' ' + LBL('Time left:') + ' ' + C(ep.timeLeft || '?') + ' ' + LBL('Slots:') + ' ' + WW(((ep.slotsDone || 0)).toLocaleString() + ' / ' + ((ep.slotsTotal || 0)).toLocaleString()) + '\n\n';
1391
+ out += ' ' + progressBar(ep.progress || 0, 52) + ' ' + G((ep.progress || 0).toFixed(1) + '%') + '\n';
1392
+ out += ' ' + LBL('Absolute slot:') + ' ' + WW((ep.absoluteSlot || 0).toLocaleString()) + ' ' + GRY('Est. end: ' + (ep.endTime || '?')) + '\n';
1393
+ out += HR(88) + '\n\n';
1394
+ }
1395
+
1396
+ // ── TPS ──
1397
+ out += ' ' + TL_BG('TPS') + ' ' + WW('Transactions per second \u2502 Recent performance samples') + '\n\n';
1398
+ out += ' ' + LBL(pad('CURRENT', T)) + LBL(pad('AVERAGE', T)) + LBL(pad('MAX', T)) + LBL('MIN') + '\n';
1399
+ out += ' ' + G(pad(tps.current + ' TPS', T)) + C(pad(tps.average + ' TPS', T)) + Y(pad(tps.maximum + ' TPS', T)) + DN(tps.minimum + ' TPS') + '\n\n';
1400
+ if (tps.history && tps.history.length > 0) {
1401
+ out += barFillChart(
1402
+ tps.history.map(function(h) { return h.value; }),
1403
+ tps.history.map(function(h) { return h.ago.replace(' mins ago', 'm'); }),
1404
+ { height: 10, colW: 3, gap: 0, axisW: 6, colTag: '#00FF88-fg', axisTag: '#00FFFF-fg' }
1405
+ ) + '\n';
1406
+ out += ' ' + LBL(pad('INTERVAL', 14)) + LBL(pad('TPS', 10)) + LBL('DELTA') + '\n';
1407
+ out += HR(44) + '\n';
1408
+ tps.history.forEach(function(h, i) {
1409
+ const prev = i > 0 ? tps.history[i - 1].value : h.value;
1410
+ const delta = i > 0 ? h.value - prev : 0;
1411
+ const dt = delta > 0 ? G('+' + delta) : delta < 0 ? DN(String(delta)) : GRY('\u2500');
1412
+ out += ' ' + WW(pad(h.ago, 14)) + G(pad(String(h.value), 10)) + dt + '\n';
1413
+ });
1414
+ }
1415
+ out += HR(88) + '\n\n';
1416
+
1417
+ // ── BLOCKTIME ──
1418
+ out += ' ' + TL_BG('BLOCKTIME') + ' ' + WW('Block time in milliseconds \u2502 Recent samples') + '\n\n';
1419
+ out += ' ' + LBL(pad('CURRENT', T)) + LBL(pad('AVERAGE', T)) + LBL(pad('MAX', T)) + LBL('MIN') + '\n';
1420
+ out += ' ' + G(pad(bt.current + ' ms', T)) + C(pad(bt.average + ' ms', T)) + Y(pad(bt.maximum + ' ms', T)) + DN(bt.minimum + ' ms') + '\n\n';
1421
+ if (bt.history && bt.history.length > 0) {
1422
+ out += barFillChart(
1423
+ bt.history.map(function(h) { return parseFloat(h.value); }),
1424
+ bt.history.map(function(h) { return h.ago.replace(' mins ago', 'm'); }),
1425
+ { height: 10, colW: 3, gap: 0, axisW: 8, colTag: '#00FFFF-fg', axisTag: '#00FFFF-fg' }
1426
+ ) + '\n';
1427
+ bt.history.forEach(function(h) {
1428
+ out += ' ' + WW(pad(h.ago, 14)) + C(h.value + ' ms') + '\n';
1429
+ });
1430
+ }
1431
+ out += HR(88) + '\n\n';
1432
+
1433
+ // ── VALIDATORS ──
1434
+ out += ' ' + TL_BG('VALIDATORS') + ' ' + WW('Top 10 by stake \u2502 getVoteAccounts') + '\n\n';
1435
+ out += ' ' + LBL(pad('#', 5)) + LBL(pad('VOTE KEY', 22)) + LBL(pad('STAKE (SOL)', 18)) + LBL('COMMISSION') + '\n';
1436
+ out += HR(60) + '\n';
1437
+ validators.forEach(function(v) {
1438
+ const cc = v.commission === '100%' ? DN(v.commission) : v.commission === '0%' ? G(v.commission) : Y(v.commission);
1439
+ out += ' ' + WW(pad(v.rank + '.', 5)) + C(pad(v.name, 22)) + G(pad(v.stake, 18)) + cc + '\n';
1440
+ });
1441
+ out += HR(88) + '\n\n';
1442
+
1443
+ // ── SOL SUPPLY ──
1444
+ out += ' ' + TL_BG('SOL SUPPLY') + ' ' + WW('via getSupply \u2502 mainnet-beta') + '\n\n';
1445
+ out += ' ' + LBL(pad('Circulating:', 22)) + C(pad(sp.circulating + 'M SOL', 16)) + ' ' + progressBar(sp.circulatingPct, 28) + ' ' + Y(sp.circulatingPct + '%') + '\n';
1446
+ out += ' ' + LBL(pad('Est. Staked:', 22)) + G(pad(sp.staked + 'M SOL', 16)) + ' ' + progressBar(sp.stakedPct, 28) + ' ' + Y(sp.stakedPct + '%') + '\n';
1447
+ out += ' ' + LBL(pad('Total supply:', 22)) + W(sp.total + 'M SOL') + '\n\n';
1448
+ out += ' ' + LBL(pad('Epoch:', 22)) + WW(String(sp.epoch)) + ' ' + LBL('Inflation:') + ' ' + DN(sp.inflationRate + '%') + '\n';
1449
+ out += ' ' + LBL(pad('Est. Staking APY:', 22)) + G(sp.stakingApy + '%') + '\n';
1450
+ out += HR(88) + '\n\n';
1451
+
1452
+ // ── STAKE DATA ──
1453
+ out += ' ' + TL_BG('STAKE DATA') + '\n\n';
1454
+ [['Total Est. Staked', sd.totalStaked], ['Est. Staking APY', sd.filterApy], ['Last updated', sd.updated]].forEach(function(pair) {
1455
+ out += ' ' + LBL(pad(pair[0] + ':', 24)) + WW(pair[1]) + '\n';
1456
+ });
1457
+ out += '\n ' + GRY('Last refreshed: ' + new Date().toLocaleString()) + '\n';
1458
+ netBox.setContent(out);
1459
+ netBox.height = 255;
1460
+ }
1461
+
1462
+ // ── Validator Geo Loader (lazy, triggered on first F7 open) ──
1463
+
1464
+ async function loadValidatorGeoData(isSilent = false) {
1465
+ if (validatorGeoLoading) return;
1466
+ validatorGeoLoading = true;
1467
+ if (!isSilent) { buildNetworkTab(); screen.render(); }
1468
+ try {
1469
+ const { fetchValidatorGeoData } = require('../api');
1470
+ validatorGeoData = await fetchValidatorGeoData();
1471
+ // ALWAYS anchor simulatedSlot to the real chain slot on (re)load
1472
+ simulatedSlot = validatorGeoData.currentSlot;
1473
+ } catch(e) {
1474
+ validatorGeoData = { totalNodes: 0, geoPoints: [], countryList: [], leaders: [] };
1475
+ } finally {
1476
+ validatorGeoLoading = false;
1477
+ buildNetworkTab(); screen.render();
1478
+ }
1479
+ }
1480
+
1481
+ // ──────────────────────────────────────────────────────────────
1482
+ // HEARTBEAT ENGINE — defined HERE so buildNetworkTab and screen
1483
+ // are in scope via closure. Module-level stub above is a no-op.
1484
+ // ──────────────────────────────────────────────────────────────
1485
+ function startHeartbeat() {
1486
+ if (heartbeatInterval) return; // idempotent
1487
+ heartbeatInterval = setInterval(() => {
1488
+ try {
1489
+ if (current !== 6) return; // only run when on Network tab
1490
+ const geo = validatorGeoData;
1491
+ if (!geo || !geo.leaderSchedule || geo.leaderSchedule.length === 0) return;
1492
+
1493
+ // +1 slot per 400ms tick. Leaders get 4 consecutive slots each,
1494
+ // so the displayed validator name changes every 4 ticks (~1.6 s).
1495
+ simulatedSlot += 1;
1496
+
1497
+ // Re-anchor if we've exhausted the 5000-slot buffer
1498
+ const offset = simulatedSlot - geo.currentSlot;
1499
+ if (offset < 0 || offset >= geo.leaderSchedule.length) {
1500
+ simulatedSlot = geo.currentSlot;
1501
+ return; // skip render this tick
1502
+ }
1503
+
1504
+ buildNetworkTab(true); // fast path: renders ribbon+map only
1505
+ screen.render();
1506
+ } catch (e) {
1507
+ // Swallow errors so interval never dies silently
1508
+ }
1509
+ }, 400);
1510
+ }
1511
+
1512
+ // ══════════════════════════════════════════════════════════
1513
+ // F8 ASK AI TERMINAL ASSISTANT
1514
+ // ══════════════════════════════════════════════════════════
1515
+ const tabAI = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
1516
+ const aiLog = mkScroll(tabAI, { top: 0, left: 0, right: 0, bottom: 4 });
1517
+
1518
+ // Status Bar for AI
1519
+ const aiStatus = blessed.box({
1520
+ parent: tabAI, bottom: 0, left: 0, width: '100%', height: 4,
1521
+ tags: true, style: { border: { fg: '#333333' } }, border: 'line',
1522
+ content: ` {#9945FF-fg}{bold}AI STATUS:{/} {#00FF88-fg}READY{/} │ {white-fg}Powered by Groq Llama 3.3{/}\n ${W('Press "i" to ask the Solana TUI Explorer AI a question...')}`
1523
+ });
1524
+
1525
+ let aiHistory = [];
1526
+
1527
+ function buildAITab() {
1528
+ if (aiLog.getContent() === '') {
1529
+ aiLog.setContent(`\n ${TL_BG(' SOLANA TUI EXPLORER AI ')} ${WW('Welcome to the Solana TUI Explorer AI assistant.')}\n\n ${GRY('Ask about Solana validator networks, accounts, tokens, or transaction errors.')}\n\n ${GRY('─────────────────────────────────────────────────────────────────────────────')}\n\n`);
1530
+ }
1531
+ }
1532
+
1533
+ async function handleAIQuery(query) {
1534
+ if (!query) return;
1535
+
1536
+ aiLog.setContent(aiLog.getContent() + ` {#00FFFF-fg}{bold}USER: ${query}{/}\n\n`);
1537
+ aiStatus.setContent(` {#9945FF-fg}{bold}AI STATUS:{/} {#FFD700-fg}THINKING...{/}\n ${GRY('Analyzing query via Groq Llama-3.3...')}`);
1538
+ screen.render();
1539
+
1540
+ try {
1541
+ const { fetchAIResponse } = require('../api');
1542
+ const response = await fetchAIResponse(query, aiHistory.slice(-6));
1543
+
1544
+ // Add to history for context
1545
+ aiHistory.push({ role: 'user', content: query });
1546
+ aiHistory.push({ role: 'assistant', content: response });
1547
+
1548
+ // Pretty print response
1549
+ const formattedResp = response.match(/.{1,95}(\s|$)/g).join('\n ');
1550
+ aiLog.setContent(aiLog.getContent() + ` {#9945FF-fg}{bold}AI:{/} \n ${W(formattedResp)}\n\n ${GRY('─────────────────────────────────────────────────────────────────────────────')}\n\n`);
1551
+ aiStatus.setContent(` {#9945FF-fg}{bold}AI STATUS:{/} {#00FF88-fg}READY{/} │ {white-fg}Tokens: ~${Math.round(response.length/4)}{/}\n ${W('Press "i" to ask another question...')}`);
1552
+ } catch (e) {
1553
+ aiLog.setContent(aiLog.getContent() + ` {#FF6B6B-fg}{bold}ERROR:{/} ${e.message}\n\n`);
1554
+ aiStatus.setContent(` {#9945FF-fg}{bold}AI STATUS:{/} {#FF6B6B-fg}ERROR{/}\n ${W('Check your GROQ_API_KEY in .env')}`);
1555
+ }
1556
+
1557
+ aiLog.setScrollPerc(100);
1558
+ screen.render();
1559
+ }
1560
+
1561
+ // ══════════════════════════════════════════════════════════
1562
+ // F9 BLOCKCHAIN EXPLORER (TX INSPECTOR)
1563
+ // ══════════════════════════════════════════════════════════
1564
+ const tabExplorer = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
1565
+ const expScroll = mkScroll(tabExplorer, { top: 0, left: 0, right: 0, bottom: 0 });
1566
+ const expBox = mkBox(expScroll, { width: '100%-2' });
1567
+
1568
+ let expTxQuery = null;
1569
+
1570
+ function buildExplorerTab() {
1571
+ let out = '';
1572
+ out += Object.keys(DATA.explorer.details || {}).length > 0 ? OB(' TRANSACTION INSPECTOR') : OB(' BLOCKCHAIN EXPLORER');
1573
+ out += ` ${WW('Deep Parse | Instructions | Profiling')} ${expTxQuery ? GRY('I=search R=refresh') : C('Press i to enter signature')}\n`;
1574
+ out += HR(88) + '\n\n';
1575
+
1576
+ if (DATA.explorer.error) {
1577
+ out += errorBanner(DATA.explorer.error) + `\n ${WW('Press')} ${C('I')} ${WW('to search another transaction.')}\n`;
1578
+ expBox.setContent(out); return;
1579
+ }
1580
+
1581
+ if (DATA.explorer.loading) {
1582
+ expBox.setContent(out + `\n\n ${TL_BG(' FETCHING TRANSACTION ')}\n\n ${Y('Analyzing blocks via high-capacity public RPC...')}\n`);
1583
+ return;
1584
+ }
1585
+
1586
+ if (!expTxQuery && (!DATA.explorer.list || DATA.explorer.list.length === 0)) {
1587
+ out += `\n ${TL_BG(' NETWORK ACTIVITY STREAM ')}\n\n ${WW('Fetching recent global transactions...')}\n`;
1588
+ expBox.setContent(out); return;
1589
+ }
1590
+
1591
+ if (!expTxQuery) {
1592
+ out += ` ${LBL('LATEST TRANSACTIONS')} ${GRY('(System Program Activity)')}\n`;
1593
+ out += ' ' + LBL(pad('TIME', 12)) + LBL(pad('STATUS', 10)) + LBL(pad('SLOT', 12)) + LBL('SIGNATURE') + '\n';
1594
+ out += HR(88) + '\n';
1595
+ DATA.explorer.list.forEach(tx => {
1596
+ const st = tx.status === 'SUCCESS' ? GRN_BG(' SUCCESS ') : RED_BG(' FAILE D ');
1597
+ out += ' ' + W(pad(tx.time, 12)) + st + ' ' + pad(String(tx.slot), 12) + C(tx.signature) + '\n';
1598
+ });
1599
+ out += `\n ${C('Type')} ${W(' i ')} ${C('to inspect a specific transaction deep-dive.')}\n`;
1600
+ expBox.setContent(out);
1601
+ expBox.height = 25;
1602
+ return;
1603
+ }
1604
+
1605
+ const tx = DATA.explorer.details;
1606
+ if (!tx) return;
1607
+
1608
+ // Overview Section
1609
+ out += ` ${TL_BG(' OVERVIEW ')}\n\n`;
1610
+ const statusBtn = tx.success ? GRN_BG(' Success ') : RED_BG(' Failed ');
1611
+ out += ` ${LBL(pad('Signature', 15))} ${C(tx.signature)}\n`;
1612
+ out += ` ${LBL(pad('Result', 15))} ${statusBtn}\n`;
1613
+ out += ` ${LBL(pad('Timestamp', 15))} ${W(tx.timestamp)}\n`;
1614
+ out += ` ${LBL(pad('Status', 15))} ${W('FINALIZED')}\n`;
1615
+ out += ` ${LBL(pad('Slot', 15))} ${W(tx.slot)}\n`;
1616
+ out += ` ${LBL(pad('Fee (SOL)', 15))} ${W('◎' + tx.fee)}\n`;
1617
+ out += ` ${LBL(pad('Compute Units', 15))} ${W(tx.cuConsumed)}\n`;
1618
+ out += ` ${LBL(pad('Version', 15))} ${W(tx.version)}\n\n`;
1619
+
1620
+ // Account Inputs
1621
+ out += ` ${TL_BG(` ACCOUNT INPUT(S) (${(tx.accounts||[]).length}) `)}\n\n`;
1622
+ out += ' ' + LBL(pad('#', 3)) + LBL(pad('ADDRESS', 44)) + LBL(pad('CHANGE (SOL)', 12)) + LBL(pad('DETAILS', 20)) + '\n';
1623
+ out += ' ' + HR(80) + '\n';
1624
+ (tx.accounts || []).forEach((acc, i) => {
1625
+ let chg = String(acc.change);
1626
+ let chgClr = chg === '0' ? GRY(pad('0', 11)) : chg.startsWith('-') ? `{#FF6B6B-fg}${pad(chg, 11)}{/}` : `{#00FF88-fg}${pad('+'+chg, 11)}{/}`;
1627
+ let badges = '';
1628
+ if (acc.feePayer) badges += '{#0066CC-bg}{#FFFFFF-fg} Payer {/} ';
1629
+ if (acc.signer) badges += '{#336699-bg}{#FFFFFF-fg} Signer {/} ';
1630
+ if (acc.writable) badges += '{#800080-bg}{#FFFFFF-fg} Writable {/} ';
1631
+ if (acc.program) badges += '{#0055AA-bg}{#FFFFFF-fg} Program {/} ';
1632
+
1633
+ out += ' ' + W(pad(String(i+1), 3)) + C(pad(acc.pubkey, 44)) + chgClr + ' ' + badges + '\n';
1634
+ });
1635
+ out += '\n';
1636
+
1637
+ // Instructions
1638
+ out += ` ${TL_BG(' INSTRUCTIONS ')}\n\n`;
1639
+ (tx.instructions || []).forEach((ix) => {
1640
+ out += ` {#114422-bg}{#00FF88-fg} #${ix.index} {/} {bold}${ix.name}{/}\n`;
1641
+ out += ` ${pad(W('Program'), 25)}${C(ix.programId)}\n`;
1642
+ if (ix.parsedParams && ix.parsedParams.length > 0) {
1643
+ ix.parsedParams.forEach(p => {
1644
+ out += ` ${pad(GRY(p.key), 25)}${C(p.value)}\n`;
1645
+ });
1646
+ } else if (ix.data) {
1647
+ out += ` ${pad(GRY('Data'), 25)}${GRY(ix.data)}\n`;
1648
+ }
1649
+ out += '\n';
1650
+ });
1651
+
1652
+ // Trace (Logs)
1653
+ out += ` ${TL_BG(' PROGRAM EXECUTION TRACE ')}\n\n`;
1654
+ if (!tx.logs || tx.logs.length === 0) {
1655
+ out += ` ${GRY('No logs available.')}\n\n`;
1656
+ } else {
1657
+ tx.logs.forEach(l => {
1658
+ let icon = '{white-fg}│{/} ', clrLine = l;
1659
+ if (l.includes('invoke')) { icon = `{#00FFFF-fg}> {/}`; clrLine = `{#00FFFF-fg}${l}{/}`; }
1660
+ else if (l.includes('success')) { icon = `{#00FF88-fg}* {/}`; clrLine = `{#00FF88-fg}${l}{/}`; }
1661
+ else if (l.includes('failed') || l.includes('Error')) { icon = `{#FF6B6B-fg}X {/}`; clrLine = `{#FF6B6B-fg}${l}{/}`; }
1662
+ else if (l.includes('consumed')) { icon = `{#FFD700-fg}! {/}`; clrLine = `{#FFD700-fg}${l}{/}`; }
1663
+ else if (l.includes('log:')) { icon = `{#AAAAAA-fg}i {/}`; clrLine = `{#AAAAAA-fg}${l}{/}`; }
1664
+ else if (l.includes('return')) { icon = `{#9945FF-fg}# {/}`; clrLine = `{#9945FF-fg}${l}{/}`; }
1665
+
1666
+ out += ` ${icon} ${clrLine}\n`;
1667
+ });
1668
+ out += '\n';
1669
+ }
1670
+
1671
+ // CU Profiling (Stacked Multi-Color Bar)
1672
+ out += `\n\n ${TL_BG(' COMPUTE UNIT PROFILING ')}\n\n`;
1673
+ out += ` ${LBL('Total Consumption:')} ${W(tx.cuConsumed.toLocaleString() + ' CU')}\n\n`;
1674
+
1675
+ const BAR_WIDTH = 85;
1676
+ const MAX_CU = 1400000;
1677
+ const colors = ['#00FF88', '#00CCBB', '#0099FF', '#9945FF', '#FFD700', '#FF6B6B', '#FF00FF', '#00FFFF'];
1678
+
1679
+ if (tx.cuUsage && tx.cuUsage.length > 0) {
1680
+ let barStr = ' ';
1681
+ let legendRows = [];
1682
+ let currentRow = ' ';
1683
+
1684
+ tx.cuUsage.forEach((usage, idx) => {
1685
+ const clr = colors[idx % colors.length];
1686
+ const segW = Math.max(1, Math.round((usage.consumed / MAX_CU) * BAR_WIDTH));
1687
+ barStr += `{${clr}-bg} ${'{/}'}`.repeat(segW);
1688
+
1689
+ const item = `{${clr}-fg}■{/} ${W('#' + (idx+1))} ${GRY(usage.consumed.toLocaleString())}`;
1690
+
1691
+ if (currentRow.replace(/{[^}]+}/g, '').length + item.replace(/{[^}]+}/g, '').length > 82) {
1692
+ legendRows.push(currentRow);
1693
+ currentRow = ' ' + item + ' ';
1694
+ } else {
1695
+ currentRow += item + ' ';
1696
+ }
1697
+ });
1698
+ legendRows.push(currentRow);
1699
+
1700
+ const visibleBarLen = barStr.replace(/{[^}]+}/g, '').length;
1701
+ if (visibleBarLen < BAR_WIDTH) {
1702
+ barStr += `${' '.repeat(BAR_WIDTH - visibleBarLen)}{/}`;
1703
+ }
1704
+
1705
+ out += barStr + '\n\n' + legendRows.join('\n') + '\n';
1706
+ } else {
1707
+ const fullBar = Math.min(BAR_WIDTH, Math.round((tx.cuConsumed / MAX_CU) * BAR_WIDTH) || 5);
1708
+ out += ` {#00FF88-bg}${' '.repeat(fullBar)}{/}\n`;
1709
+ }
1710
+
1711
+ expBox.setContent(out);
1712
+ expBox.height = 60 + (tx.accounts||[]).length + (tx.instructions||[]).length * 6 + (tx.logs||[]).length + 15;
1713
+ }
1714
+
1715
+ async function loadExplorerDetails(sig) {
1716
+ expTxQuery = sig;
1717
+ DATA.explorer.loading = true;
1718
+ buildExplorerTab(); screen.render();
1719
+ const { loadExplorerDetails } = require('../data');
1720
+ await loadExplorerDetails(sig);
1721
+ buildExplorerTab(); screen.render();
1722
+ }
1723
+
1724
+ async function refreshExplorerList() {
1725
+ expTxQuery = null;
1726
+ DATA.explorer.loading = true;
1727
+ buildExplorerTab(); screen.render();
1728
+ const { loadExplorerList } = require('../data');
1729
+ await loadExplorerList();
1730
+ buildExplorerTab(); screen.render();
1731
+ }
1732
+
1733
+ // ══════════════════════════════════════════════════════════
1734
+ // F6 LITESVM LOCALHOST TESTING
1735
+ // ══════════════════════════════════════════════════════════
1736
+ const tabLocalTest = blessed.box({ parent: mainPane, width: '100%', height: '100%', hidden: true, tags: true, style: BOX });
1737
+
1738
+ // Left List Pane for transactions
1739
+ localList = blessed.list({
1740
+ parent: tabLocalTest,
1741
+ top: 2,
1742
+ left: 1,
1743
+ width: '40%',
1744
+ bottom: 1,
1745
+ keys: true,
1746
+ vi: true,
1747
+ mouse: true,
1748
+ interactive: true,
1749
+ border: 'line',
1750
+ label: ' {#00FF88-fg}{bold}LITESVM TRANSACTIONS{/} ',
1751
+ tags: true,
1752
+ style: {
1753
+ border: { fg: '#00FF88' },
1754
+ selected: { bg: '#00FF88', fg: 'black', bold: true },
1755
+ item: { fg: 'white', bg: '#001A0D' }
1756
+ }
1757
+ });
1758
+
1759
+ // Right Details Pane scroll wrapper & text box
1760
+ const localDetailScroll = mkScroll(tabLocalTest, {
1761
+ top: 2,
1762
+ right: 1,
1763
+ width: '58%',
1764
+ bottom: 1,
1765
+ border: 'line',
1766
+ style: { border: { fg: '#00FFFF' } },
1767
+ label: ' {#00FFFF-fg}{bold}TRANSACTION LOGS & DETAILS{/} ',
1768
+ tags: true,
1769
+ keys: true,
1770
+ vi: true,
1771
+ mouse: true,
1772
+ interactive: true
1773
+ });
1774
+ const localDetailBox = mkBox(localDetailScroll, { width: '100%-2' });
1775
+
1776
+ let localTransactions = [];
1777
+ let localSelectedIdx = 0;
1778
+
1779
+ function findSessionPath() {
1780
+ const fs = require('fs');
1781
+ const path = require('path');
1782
+
1783
+ let rawPath = CFG.LITESVM_SESSION_PATH;
1784
+ if (process.platform === 'linux' && (rawPath.startsWith('\\\\') || rawPath.startsWith('//'))) {
1785
+ let normalized = rawPath.replace(/\\/g, '/');
1786
+ normalized = normalized.replace(/^\/\/wsl\.localhost\/[^\/]+/, '');
1787
+ normalized = normalized.replace(/^\/\/wsl\$\/[^\/]+/, '');
1788
+ rawPath = normalized;
1789
+ }
1790
+
1791
+ let sessionPath = path.resolve(process.cwd(), rawPath);
1792
+ if (fs.existsSync(sessionPath)) return sessionPath;
1793
+
1794
+ const parentPath = path.resolve(process.cwd(), '../litesvm-session.json');
1795
+ if (fs.existsSync(parentPath)) return parentPath;
1796
+
1797
+ const grandPath = path.resolve(process.cwd(), '../../litesvm-session.json');
1798
+ if (fs.existsSync(grandPath)) return grandPath;
1799
+
1800
+ try {
1801
+ const parentDir = path.dirname(process.cwd());
1802
+ const siblings = fs.readdirSync(parentDir);
1803
+ for (const sibling of siblings) {
1804
+ const siblingPath = path.resolve(parentDir, sibling, 'litesvm-session.json');
1805
+ if (fs.existsSync(siblingPath)) {
1806
+ return siblingPath;
1807
+ }
1808
+ }
1809
+ } catch (e) {}
1810
+
1811
+ return sessionPath;
1812
+ }
1813
+
1814
+ // Load data from litesvm-session.json
1815
+ function loadLocalTestData() {
1816
+ const fs = require('fs');
1817
+ const sessionPath = findSessionPath();
1818
+
1819
+ if (!fs.existsSync(sessionPath)) {
1820
+ localTransactions = [];
1821
+ buildLocalTestTabEmpty();
1822
+ return;
1823
+ }
1824
+
1825
+ try {
1826
+ const fileContent = fs.readFileSync(sessionPath, 'utf8');
1827
+ const lines = fileContent.split('\n').filter(l => l.trim() !== '');
1828
+
1829
+ localTransactions = lines.map(line => {
1830
+ try {
1831
+ return JSON.parse(line);
1832
+ } catch (e) {
1833
+ return null;
1834
+ }
1835
+ }).filter(t => t !== null);
1836
+
1837
+ buildLocalTestTabContent();
1838
+ } catch (err) {
1839
+ localTransactions = [];
1840
+ localDetailBox.setContent(`\n ${RED_BG('ERROR')} {#FF6B6B-fg}Failed to read session file: ${err.message}{/}\n`);
1841
+ localDetailBox.height = 5;
1842
+ screen.render();
1843
+ }
1844
+ }
1845
+
1846
+ function buildLocalTestTabEmpty() {
1847
+ localList.hide();
1848
+ localDetailScroll.hide();
1849
+
1850
+ // Welcome helper pane
1851
+ const helpBox = blessed.box({
1852
+ parent: tabLocalTest,
1853
+ top: 2,
1854
+ left: 2,
1855
+ right: 2,
1856
+ bottom: 1,
1857
+ tags: true,
1858
+ style: BOX,
1859
+ content: `\n ${TL_BG(' LITESVM LOCAL TESTING ')} ${WW('Record and inspect in-memory Rust test transactions')}\n\n` +
1860
+ ` ${C('Status:')} {#FF6B6B-fg}No session logs found at '${CFG.LITESVM_SESSION_PATH}'{/}\n\n` +
1861
+ ` ${WW('LiteSVM executes entirely in-memory during tests, so transaction logs disappear when tests exit.')}\n` +
1862
+ ` ${WW('To capture and view transactions here, wrap LiteSVM in your Rust test suite using our logger:')}\n\n` +
1863
+ ` ${GRY('1. Copy our wrapper module into your test suite:')}\n` +
1864
+ ` ${W('tests/litesvm_explorer.rs')}\n\n` +
1865
+ ` ${GRY('2. Use the wrapper instead of LiteSVM directly in your tests:')}\n` +
1866
+ ` {#00FF88-fg}#[test]\n` +
1867
+ ` fn test_escrow() {\n` +
1868
+ ` let mut svm = ExplorerLiteSVM::new(); // logs automatically\n` +
1869
+ ` svm.send_transaction(tx).unwrap();\n` +
1870
+ ` }{/}\n\n` +
1871
+ ` ${GRY('3. Run your tests with:')}\n` +
1872
+ ` ${W('cargo test')}\n\n` +
1873
+ ` ${WW('This tab will automatically watch the session file and render transaction CPIs & logs in real-time!')}`
1874
+ });
1875
+ tabLocalTest._helpBox = helpBox;
1876
+ screen.render();
1877
+ }
1878
+
1879
+ function buildLocalTestTabContent() {
1880
+ if (tabLocalTest._helpBox) {
1881
+ tabLocalTest._helpBox.destroy();
1882
+ delete tabLocalTest._helpBox;
1883
+ }
1884
+
1885
+ localList.show();
1886
+ localDetailScroll.show();
1887
+
1888
+ if (localTransactions.length === 0) {
1889
+ localList.setItems([` No transactions recorded yet `]);
1890
+ localDetailBox.setContent(`\n ${WW('Run cargo test in your project to see transaction details.')}\n`);
1891
+ localDetailBox.height = 5;
1892
+ screen.render();
1893
+ return;
1894
+ }
1895
+
1896
+ const items = localTransactions.map((tx, idx) => {
1897
+ const statusIcon = tx.status === 'Success' ? '{#00FF88-fg}✓{/}' : '{#FF6B6B-fg}✗{/}';
1898
+ const signatureShort = tx.signature.slice(0, 12) + '…' + tx.signature.signature || tx.signature.slice(-8);
1899
+ const sigFinal = tx.signature.length > 20 ? (tx.signature.slice(0, 12) + '…' + tx.signature.slice(-8)) : tx.signature;
1900
+ return ` ${statusIcon} ${sigFinal} ${GRY(new Date(tx.timestamp * 1000).toLocaleTimeString())}`;
1901
+ });
1902
+
1903
+ localList.setItems(items);
1904
+
1905
+ if (localSelectedIdx >= localTransactions.length) {
1906
+ localSelectedIdx = localTransactions.length - 1;
1907
+ }
1908
+ localList.select(localSelectedIdx);
1909
+ renderLocalDetail(localTransactions[localSelectedIdx]);
1910
+ screen.render();
1911
+ }
1912
+
1913
+ function renderLocalDetail(tx) {
1914
+ if (!tx) {
1915
+ localDetailBox.setContent('');
1916
+ return;
1917
+ }
1918
+
1919
+ const statusBg = tx.status === 'Success' ? GRN_BG(' SUCCESS ') : RED_BG(' FAILED ');
1920
+ const timeStr = new Date(tx.timestamp * 1000).toLocaleString();
1921
+
1922
+ let out = '';
1923
+ out += ` ${statusBg} ${W(tx.signature)}\n`;
1924
+ out += ` ${LBL('Execution Time:')} ${WW(timeStr)}\n`;
1925
+ out += ` ${LBL('Compute Units:')} ${Y(tx.compute_units.toLocaleString())} CU\n`;
1926
+ if (tx.error) {
1927
+ out += ` ${LBL('Error Code:')} {#FF6B6B-fg}{bold}${tx.error}{/}\n`;
1928
+ }
1929
+ out += HR(80) + '\n\n';
1930
+
1931
+ out += ` ${TL_BG(' PROGRAM EXECUTION LOGS ')}\n\n`;
1932
+ if (tx.logs && tx.logs.length > 0) {
1933
+ tx.logs.forEach(log => {
1934
+ let color = '{white-fg}';
1935
+ if (log.includes('failed')) color = '{#FF6B6B-fg}';
1936
+ else if (log.includes('success')) color = '{#00FF88-fg}';
1937
+ else if (log.includes('Instruction:')) color = '{#00FFFF-fg}{bold}';
1938
+
1939
+ out += ` ${color}${log}{/}\n`;
1940
+ });
1941
+ } else {
1942
+ out += ` ${GRY('(No program logs generated)')}\n`;
1943
+ }
1944
+
1945
+ localDetailBox.setContent(out);
1946
+ localDetailBox.height = out.split('\n').length + 5;
1947
+ localDetailScroll.setScrollPerc(0);
1948
+ screen.render();
1949
+ }
1950
+
1951
+ localList.on('select item', (el, idx) => {
1952
+ localSelectedIdx = idx;
1953
+ if (localTransactions[idx]) {
1954
+ renderLocalDetail(localTransactions[idx]);
1955
+ }
1956
+ });
1957
+
1958
+ // Toggle focus between Left and Right panes with Tab or Left/Right arrows
1959
+ localList.key(['tab', 'right'], () => {
1960
+ localDetailScroll.focus();
1961
+ });
1962
+
1963
+ localDetailScroll.key(['tab', 'left'], () => {
1964
+ localList.focus();
1965
+ });
1966
+
1967
+ localList.on('click', () => {
1968
+ localList.focus();
1969
+ });
1970
+
1971
+ localDetailScroll.on('click', () => {
1972
+ localDetailScroll.focus();
1973
+ });
1974
+
1975
+ // Dynamic border highlight on focus for visual clarity
1976
+ localList.on('focus', () => {
1977
+ localList.style.border.fg = '#00FF88';
1978
+ localList.setLabel(' {#00FF88-fg}{bold}LITESVM TRANSACTIONS{/} ');
1979
+ localDetailScroll.style.border.fg = '#335555';
1980
+ localDetailScroll.setLabel(' {#335555-fg}TRANSACTION LOGS & DETAILS{/} ');
1981
+ screen.render();
1982
+ });
1983
+
1984
+ localList.on('blur', () => {
1985
+ localList.style.border.fg = '#224433';
1986
+ localList.setLabel(' {#224433-fg}LITESVM TRANSACTIONS{/} ');
1987
+ screen.render();
1988
+ });
1989
+
1990
+ localDetailScroll.on('focus', () => {
1991
+ localDetailScroll.style.border.fg = '#00FFFF';
1992
+ localDetailScroll.setLabel(' {#00FFFF-fg}{bold}TRANSACTION LOGS & DETAILS{/} ');
1993
+ localList.style.border.fg = '#224433';
1994
+ localList.setLabel(' {#224433-fg}LITESVM TRANSACTIONS{/} ');
1995
+ screen.render();
1996
+ });
1997
+
1998
+ localDetailScroll.on('blur', () => {
1999
+ localDetailScroll.style.border.fg = '#335555';
2000
+ localDetailScroll.setLabel(' {#335555-fg}TRANSACTION LOGS & DETAILS{/} ');
2001
+ screen.render();
2002
+ });
2003
+
2004
+ let localInterval = null;
2005
+ let lastMtime = 0;
2006
+ let lastSize = 0;
2007
+
2008
+ function watchLocalSessionFile() {
2009
+ const fs = require('fs');
2010
+
2011
+ if (localInterval) return;
2012
+
2013
+ localInterval = setInterval(() => {
2014
+ const sessionPath = findSessionPath();
2015
+ if (fs.existsSync(sessionPath)) {
2016
+ try {
2017
+ const stat = fs.statSync(sessionPath);
2018
+ const mtime = stat.mtimeMs;
2019
+ const size = stat.size;
2020
+ if (mtime !== lastMtime || size !== lastSize) {
2021
+ lastMtime = mtime;
2022
+ lastSize = size;
2023
+ loadLocalTestData();
2024
+ }
2025
+ } catch (e) {}
2026
+ } else {
2027
+ if (lastSize !== 0) {
2028
+ lastMtime = 0;
2029
+ lastSize = 0;
2030
+ loadLocalTestData();
2031
+ }
2032
+ }
2033
+ }, 1000);
2034
+ }
2035
+
2036
+ const hints = [
2037
+ 'Solana RPC stats │ Validator World Map │ R=refresh │ 30s auto-refresh',
2038
+ 'I=enter wallet address │ R=refresh │ arrows=scroll',
2039
+ 'I=enter token mint or symbol │ R=refresh │ arrows=scroll │ T=change timeframe',
2040
+ 'Ask the Solana Developer AI assistant │ I=ask a question',
2041
+ 'Blockchain deep-dive │ I=inspect signature │ R=refresh live feed',
2042
+ 'LiteSVM session monitor │ TAB/Arrows=switch pane │ arrows/vi=scroll │ R=refresh'
2043
+ ];
2044
+ const cmdBar = blessed.box({
2045
+ parent: root, bottom: 1, left: 0, width: '100%', height: 1,
2046
+ tags: true, style: BOX,
2047
+ content: `{#00FFFF-fg}▶{/} {white-fg}Connecting to Solana RPC...{/}`,
2048
+ });
2049
+ const footer = blessed.box({
2050
+ parent: root, bottom: 0, left: 0, width: '100%', height: 1,
2051
+ tags: true, style: { bg: '#003333', fg: 'white' },
2052
+ });
2053
+
2054
+ // ─────────────────────────────────────────────
2055
+ // TAB MANAGEMENT
2056
+ // ─────────────────────────────────────────────
2057
+ const allTabs = [tabNetwork, tabWallet, tabToken, tabAI, tabExplorer, tabLocalTest];
2058
+ const allScrolls = [netScroll, wltScroll, tokScroll, aiLog, expScroll, localDetailScroll];
2059
+
2060
+ function activateTab(idx) {
2061
+ current = idx;
2062
+ allTabs.forEach((t, i) => (i === idx ? t.show() : t.hide()));
2063
+ activeScroll = allScrolls[idx] || null;
2064
+ if (activeScroll) activeScroll.setScrollPerc(0);
2065
+
2066
+ // Lazy-load validator geo data on first Network tab open (idx 0 = F1)
2067
+ if (idx === 0 && validatorGeoData === null && !validatorGeoLoading) {
2068
+ loadValidatorGeoData();
2069
+ startHeartbeat();
2070
+ }
2071
+
2072
+ if (idx === 4 && !expTxQuery && (!DATA.explorer.list || DATA.explorer.list.length === 0)) {
2073
+ refreshExplorerList();
2074
+ }
2075
+
2076
+ if (idx === 5) {
2077
+ loadLocalTestData();
2078
+ watchLocalSessionFile();
2079
+ localList.focus();
2080
+ }
2081
+
2082
+ const names = ['NETWORK', 'ACCOUNT', 'TOKEN', 'ASK AI', 'EXPLORER', 'LITESVM'];
2083
+ let nav = '';
2084
+ names.forEach((n, i) => {
2085
+ nav += i === idx
2086
+ ? ` {#FFFFFF-fg}{#0066CC-bg}{bold} F${i+1} ${n} {/}`
2087
+ : ` {#002222-bg}{#00FFFF-fg} F${i+1} {/}{white-fg} ${n} {/}`;
2088
+ });
2089
+ navContent.setContent(nav);
2090
+
2091
+ const fLine = names.map((n, i) => `{#00FFFF-fg}{bold}F${i+1}{/} {white-fg}${n}{/}`).join(' ');
2092
+ footer.setContent(` {white-fg}${fLine} I=input R=refresh ↑↓=scroll ESC=quit SOLANA DEV EXPLORER{/}`);
2093
+ cmdBar.setContent(`{#00FFFF-fg}▶{/} {white-fg}${hints[idx] || ''}{/}`);
2094
+ screen.render();
2095
+ }
2096
+
2097
+ screen.key(['f1'], () => activateTab(0));
2098
+ screen.key(['f2'], () => activateTab(1));
2099
+ screen.key(['f3'], () => activateTab(2));
2100
+ screen.key(['f4'], () => activateTab(3));
2101
+ screen.key(['f5'], () => activateTab(4));
2102
+ screen.key(['f6'], () => activateTab(5));
2103
+ screen.key(['escape', 'q', 'Q', 'C-c'], (ch, key) => {
2104
+ if (key && key.name === 'c' && key.ctrl) return process.exit(0);
2105
+ if (activeModal) {
2106
+ activeModal.destroy();
2107
+ activeModal = null;
2108
+ screen.render();
2109
+ return;
2110
+ }
2111
+ process.exit(0);
2112
+ });
2113
+
2114
+ screen.key(['i', 'I'], () => {
2115
+ if (current === 1) { // Wallet tab
2116
+ getLineInput(screen, 'Enter Solana wallet address (base58 public key):', addr => {
2117
+ if (addr) loadWallet(addr); else buildWalletTab(); screen.render();
2118
+ });
2119
+ } else if (current === 2) { // Token tab
2120
+ getLineInput(screen, 'Enter token mint address or symbol (e.g. BONK / WIF / JUP):', val => {
2121
+ if (val) loadToken(val); else buildTokenTab(); screen.render();
2122
+ });
2123
+ } else if (current === 3) { // AI tab
2124
+ getLineInput(screen, 'ASK SOLANA DEV AI:', query => {
2125
+ if (query) handleAIQuery(query);
2126
+ });
2127
+ } else if (current === 4) { // Explorer tab
2128
+ getLineInput(screen, 'Enter Transaction Signature to inspect (base58):', sig => {
2129
+ if (sig) loadExplorerDetails(sig); else refreshExplorerList();
2130
+ });
2131
+ }
2132
+ });
2133
+
2134
+ screen.key(['r', 'R'], () => {
2135
+ if (current === 0) refreshNetwork();
2136
+ else if (current === 1 && walletAddr) loadWallet(walletAddr);
2137
+ else if (current === 2 && tokenQuery) loadToken(tokenQuery);
2138
+ else if (current === 3) buildAITab();
2139
+ else if (current === 4) { if (expTxQuery) loadExplorerDetails(expTxQuery); else refreshExplorerList(); }
2140
+ else if (current === 5) loadLocalTestData();
2141
+ });
2142
+
2143
+ screen.key(['t', 'T'], () => {
2144
+ if (current === 2 && tokenQuery && !tokenLoading) {
2145
+ getSelectionMenu(screen, 'SELECT TIMEFRAME', ['5M', '1H', '1D'], val => {
2146
+ if (val) {
2147
+ loadToken(null, val);
2148
+ } else {
2149
+ buildTokenTab(); screen.render();
2150
+ }
2151
+ });
2152
+ }
2153
+ });
2154
+
2155
+ // ─────────────────────────────────────────────
2156
+ // DATA LOADERS
2157
+ // ─────────────────────────────────────────────
2158
+ async function refreshNetwork(isSilent = false) {
2159
+ // If we already have data, refresh silently (no spinner, old data stays)
2160
+ const showSpinner = !netHasData && !isSilent;
2161
+ if (showSpinner) {
2162
+ netLoading = true; netError = null;
2163
+ buildNetworkTab(); screen.render();
2164
+ } else {
2165
+ netLoading = true; netError = null; // flag as loading but don't re-render spinner
2166
+ }
2167
+ try {
2168
+ await loadNetworkData();
2169
+ netLoading = false;
2170
+ netHasData = true; // mark that we have real data now
2171
+ } catch (e) {
2172
+ netLoading = false;
2173
+ netError = e.message.substring(0, 80);
2174
+ }
2175
+ buildNetworkTab(); screen.render();
2176
+ }
2177
+
2178
+ // ─────────────────────────────────────────────
2179
+ // BOOT SEQUENCE
2180
+ // ─────────────────────────────────────────────
2181
+ activateTab(0);
2182
+ buildNetworkTab();
2183
+ buildWalletTab(); buildTokenTab(); buildAITab();
2184
+
2185
+ // 15-minute silent resync of geo+schedule data (keeps heartbeat in sync with chain)
2186
+ setInterval(() => {
2187
+ if (!validatorGeoLoading) {
2188
+ loadValidatorGeoData(true); // silent = no loading spinner
2189
+ }
2190
+ }, 15 * 60 * 1000); // 15 minutes
2191
+
2192
+ screen.render();
2193
+
2194
+ // Initial data loads
2195
+ Promise.all([refreshNetwork()]).then(() => screen.render());
2196
+ setInterval(refreshNetwork, CFG.NETWORK_REFRESH_MS);
2197
+ }
2198
+
2199
+ module.exports = { startDashboard };