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.
- package/api.js +1786 -0
- package/bin/st.js +3 -0
- package/config.js +87 -0
- package/data.js +197 -0
- package/index.js +61 -0
- package/package.json +51 -0
- package/panels/dashboard.js +2199 -0
- package/panels/token.js +53 -0
- package/panels/wallet.js +55 -0
|
@@ -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 };
|