nat-cli 0.0.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui.mjs ADDED
@@ -0,0 +1,671 @@
1
+ // Terminal UI for `bnat` with no arguments: one dashboard screen (wallet line, status, error
2
+ // summary, a tabbed table, a timestamped log, key bar). Raw keys in, ANSI frames out.
3
+ //
4
+ // Every action runs this same CLI as a child (`bnat <cmd> … --json`), so the checks, simulation,
5
+ // wallet-preview validation and journal are exactly the ones the commands use. A write is first
6
+ // run without --yes (checks + simulation only, nothing sent) and becomes the pending plan; only
7
+ // `y` runs it again with --yes, which hands it to baw. While a transaction executes, quitting is
8
+ // refused: an interrupted run would leave its journal entry open.
9
+ //
10
+ // Login: if `baw wallet status` is not CONNECTED, the table panel shows the QR code from
11
+ // `baw auth signin` while `baw auth verify` waits for the scan (stopped at expiry, on Esc or quit).
12
+ import { spawn } from 'node:child_process';
13
+ import { appendFileSync, mkdirSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { emitKeypressEvents } from 'node:readline';
17
+ import { fileURLToPath } from 'node:url';
18
+ import QRCode from 'qrcode';
19
+ import { getAddress, isAddress } from 'viem';
20
+ import { baw, data } from './baw.mjs';
21
+ import { BATCH_MAX as BATCH, wrapAmounts } from './chain.mjs';
22
+ import { configFile, lang, saveSetting, setLang, settings, t } from './i18n.mjs';
23
+ import { stateDir } from './journal.mjs';
24
+
25
+ const CLI = fileURLToPath(new URL('../scripts/cli.mjs', import.meta.url));
26
+ const PAGE = 50; // rows per page (ranks 1–50, 51–100, …)
27
+ const TOP_DEPTH = 10_000; // how deep /top ranks
28
+
29
+ /** Run `bnat <args> --json` and return its JSON envelope ({ ok, result, log, error, stopped }). */
30
+ export function runCli(args) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(process.execPath, ['--no-warnings', CLI, ...args, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
33
+ let out = '', err = '';
34
+ child.stdout.on('data', (d) => { out += d; });
35
+ child.stderr.on('data', (d) => { err += d; });
36
+ child.on('error', (e) => resolve({ ok: false, error: e.message, log: [] }));
37
+ child.on('close', () => {
38
+ try { resolve(JSON.parse(out)); } catch { resolve({ ok: false, error: (err || out).trim().slice(-400) || t('tui.noOutput'), log: [] }); }
39
+ });
40
+ });
41
+ }
42
+
43
+ /** The baw login calls the TUI makes (injectable for tests). */
44
+ export const bawAuth = {
45
+ async status() { return data(await baw(['wallet', 'status']), 'baw wallet status').status; },
46
+ async signin() { return data(await baw(['auth', 'signin']), 'baw auth signin'); },
47
+ async verify(qrCodeId, signal) { return data(await baw(['auth', 'verify', '--qrCodeId', qrCodeId], { signal }), 'baw auth verify'); },
48
+ };
49
+
50
+ /** A QR code as text rows of half blocks (two modules per row, 2-module quiet zone). */
51
+ export function qrLines(text) {
52
+ const q = QRCode.create(text, { errorCorrectionLevel: 'M' });
53
+ const n = q.modules.size, m = 2;
54
+ const dark = (r, c) => r >= 0 && c >= 0 && r < n && c < n && !!q.modules.get(r, c);
55
+ const out = [];
56
+ for (let r = -m; r < n + m; r += 2) {
57
+ let line = '';
58
+ for (let c = -m; c < n + m; c++) {
59
+ const top = dark(r, c), bottom = dark(r + 1, c);
60
+ line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
61
+ }
62
+ out.push(line);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // ---- text width (CJK and full-width forms take two columns) ----
68
+ const wide = (c) => (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3)
69
+ || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6);
70
+ const cw = (ch) => (wide(ch.codePointAt(0)) ? 2 : 1);
71
+ export const width = (s) => { let w = 0; for (const ch of s) w += cw(ch); return w; };
72
+ /** Cut or pad `s` to exactly `cols` columns. */
73
+ export function fit(s, cols) {
74
+ let out = '', w = 0;
75
+ for (const ch of s) {
76
+ if (w + cw(ch) > cols) break;
77
+ out += ch; w += cw(ch);
78
+ }
79
+ return out + ' '.repeat(Math.max(0, cols - w));
80
+ }
81
+ function wrapText(s, cols) {
82
+ const out = [];
83
+ for (const line of String(s).split('\n')) {
84
+ let cur = '', w = 0;
85
+ for (const ch of line) {
86
+ if (w + cw(ch) > cols) { out.push(cur); cur = ''; w = 0; }
87
+ cur += ch; w += cw(ch);
88
+ }
89
+ out.push(cur);
90
+ }
91
+ return out;
92
+ }
93
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)));
94
+ const lpad = (s, n) => ' '.repeat(Math.max(0, n - width(s))) + s;
95
+ const short = (a) => (a ? `${a.slice(0, 6)}…${a.slice(-4)}` : '');
96
+ const group = (s) => String(s).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
97
+ /** 18-decimal integer -> decimal string without trailing zeros (cut, not rounded, to `dp`). */
98
+ export function units(v, dp = 18) {
99
+ const b = BigInt(v), neg = b < 0n, a = neg ? -b : b;
100
+ const frac = (a % 10n ** 18n).toString().padStart(18, '0').slice(0, dp).replace(/0+$/, '');
101
+ return `${neg ? '-' : ''}${a / 10n ** 18n}${frac ? `.${frac}` : ''}`;
102
+ }
103
+ const gweiBnb = (g) => units(BigInt(g) * 10n ** 9n);
104
+ const hhmmss = (d) => d.toTimeString().slice(0, 8);
105
+ const tilde = (p) => (p.startsWith(homedir()) ? `~${p.slice(homedir().length)}` : p);
106
+
107
+ // ---- the fields each tab's rows are drawn from ----
108
+ const intLike = (v) => (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) || (typeof v === 'string' && /^\d+$/.test(v));
109
+ const blockRow = (x) => !!x && intLike(x.blk) && intLike(x.burnGwei);
110
+ const ROW_OK = {
111
+ blocks: blockRow, held: blockRow, vault: blockRow,
112
+ market: (b) => !!b && intLike(b.id) && intLike(b.price) && intLike(b.remaining) && intLike(b.escrow) && /^0x[0-9a-fA-F]{40}$/.test(b.buyer ?? ''),
113
+ history: (e) => !!e && typeof e.status === 'string' && typeof e.action === 'string',
114
+ };
115
+
116
+ // ---- styled segments: a line is [[text, style], …] ----
117
+ const RESET = '\x1b[0m';
118
+ const ST = {
119
+ dim: '\x1b[2m', b: '\x1b[1m', ok: '\x1b[32m', bad: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', sel: '\x1b[7m',
120
+ frame: '\x1b[90m', qr: '\x1b[47m\x1b[30m', tab: '\x1b[1;36m', badb: '\x1b[1;31m', okb: '\x1b[1;32m',
121
+ };
122
+ /** Segments cut/padded to `cols`; `base` (the selected-row style) sits under every segment. */
123
+ function paint(segs, cols, base) {
124
+ let out = '', w = 0;
125
+ const pre = base ? ST[base] : '';
126
+ for (const [text, s] of segs) {
127
+ let piece = '';
128
+ for (const ch of String(text)) {
129
+ if (w + cw(ch) > cols) break;
130
+ piece += ch; w += cw(ch);
131
+ }
132
+ if (piece) out += pre + (s ? ST[s] : '') + piece + RESET;
133
+ if (w >= cols) break;
134
+ }
135
+ return out + pre + ' '.repeat(Math.max(0, cols - w)) + RESET;
136
+ }
137
+ const segWidth = (segs) => segs.reduce((a, [text]) => a + width(String(text)), 0);
138
+
139
+ // Text keys, looked up at draw time so `i` (language switch) applies at once.
140
+ const TABS = [
141
+ { key: 'blocks', name: 'tui.mintable' },
142
+ { key: 'held', name: 'tui.holdings', multi: true }, { key: 'vault', name: 'tui.vault', multi: true },
143
+ { key: 'market', name: 'tui.market' }, { key: 'history', name: 'tui.history' },
144
+ ];
145
+ const KEYS = {
146
+ blocks: 'tui.enterMintTMintTo2',
147
+ held: 'tui.spacePickAPickPage',
148
+ vault: 'tui.spacePickAPickPage2',
149
+ market: 'tui.enterSellBPlaceBid',
150
+ history: 'tui.hSettleByHashX',
151
+ };
152
+
153
+ /**
154
+ * Start the TUI. I/O is injectable for tests: `run` replaces the CLI runner, `auth` the baw
155
+ * login calls, `input`/`output` the terminal, `size` the window size, `logDir` the log folder.
156
+ */
157
+ export function runTui({ input = process.stdin, output = process.stdout, run = runCli, auth = bawAuth, size, logDir } = {}) {
158
+ const dims = size ?? (() => ({ cols: output.columns || 100, rows: output.rows || 30 }));
159
+ const dir = logDir ?? join(stateDir(), 'logs');
160
+ const logFile = join(dir, `tui-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`);
161
+ try { mkdirSync(dir, { recursive: true }); } catch { /* the on-screen log still works */ }
162
+
163
+ return new Promise((resolveQuit) => {
164
+ const tabs = TABS.map((tb) => ({ ...tb, rows: [], i: 0, top: 0, offset: 0, total: null, err: null, loaded: false, picked: new Map(), sort: 'burn', mine: false }));
165
+ let tab = 0;
166
+ let me = null, meErr = null; // wallet summary from `bnat wallet`
167
+ let login = { state: 'checking' }; // state: checking | CONNECTED | UNCONNECTED | CREATING | error
168
+ let showLogin = false, loginTick = null;
169
+ const inflight = []; // running children; one spinner timer while any is in flight
170
+ let spin = 0, timer = null, executing = false;
171
+ let pending = null; // { args, title, after, planned:Set } waiting for y
172
+ let prompt = null; // { label, value, resolve }
173
+ let status = null; // { t, s } one-line message
174
+ const log = []; // { at, t, s }
175
+ let logBack = 0; // log lines scrolled up from the bottom
176
+ const errors = new Map(); // reason -> count
177
+ let quitting = false;
178
+ let mintTo = settings().mintTo ?? null; // default mint recipient (`bnat config mintTo`); null = this wallet
179
+
180
+ // ---------------- log ----------------
181
+ function note(kind, text, s) {
182
+ const at = new Date();
183
+ log.push({ at, t: text, s });
184
+ if (log.length > 2000) log.splice(0, log.length - 2000);
185
+ try { appendFileSync(logFile, `${JSON.stringify({ at: at.toISOString(), kind, text })}\n`); } catch { /* best effort */ }
186
+ draw();
187
+ }
188
+ function fail(r, what) {
189
+ const reason = `${r.stopped ? t('cli.stopped') : t('cli.error')}${t('common.colon')}${r.error}`;
190
+ errors.set(reason, (errors.get(reason) ?? 0) + 1);
191
+ note('error', `${what}${t('common.colon')}${reason}`, 'bad');
192
+ }
193
+
194
+ // ---------------- children ----------------
195
+ async function exec(args, label, { write = false, checking = false } = {}) {
196
+ const job = { label, checking };
197
+ inflight.push(job);
198
+ if (write) executing = true;
199
+ if (!quitting) timer ??= setInterval(() => { spin++; draw(); }, 120);
200
+ draw();
201
+ try { return await run(args); } finally {
202
+ inflight.splice(inflight.indexOf(job), 1);
203
+ if (write) executing = false;
204
+ if (!inflight.length) { clearInterval(timer); timer = null; }
205
+ draw();
206
+ }
207
+ }
208
+ async function loadMe() {
209
+ const r = await exec(['wallet'], t('tui.readingWallet'));
210
+ if (r.ok) { me = r.result; meErr = null; } else meErr = r.error;
211
+ draw();
212
+ }
213
+ async function load(tb) {
214
+ tb.loaded = true; tb.err = null;
215
+ const off = String(tb.offset);
216
+ const r = await ({
217
+ // Unminted blocks ranked by burn, 10,000 deep.
218
+ blocks: () => exec(['top', '--filter', 'unminted', '--size', String(PAGE), '--offset', off], t('tui.readingMintableBlocks')),
219
+ held: () => exec(['holdings', '--size', String(PAGE), '--offset', off], t('tui.readingHoldings')),
220
+ vault: () => exec(['vault', '--sort', tb.sort, '--size', String(PAGE), '--offset', off], t('tui.readingVault')),
221
+ market: () => exec(['bids', '--size', String(PAGE), '--offset', off, ...(tb.mine ? ['--mine'] : [])], t('tui.readingBids')),
222
+ history: () => exec(['history'], t('tui.readingHistory')),
223
+ })[tb.key]();
224
+ if (!r.ok) { tb.err = r.error; tb.rows = []; return draw(); }
225
+ const v = r.result;
226
+ const rows = tb.key === 'held' ? v?.held : tb.key === 'vault' ? v?.rows : v;
227
+ // Check the shape of the list and of every row the table will render: don't crash on it.
228
+ if (!Array.isArray(rows) || !rows.every(ROW_OK[tb.key])) { tb.err = t('tui.unexpectedReply'); tb.rows = []; return draw(); }
229
+ tb.rows = tb.key === 'history' ? [...rows].reverse() : rows;
230
+ tb.total = tb.key === 'held' ? v.heldTotal : tb.key === 'vault' ? v.total : null;
231
+ tb.i = Math.min(tb.i, Math.max(0, tb.rows.length - 1));
232
+ draw();
233
+ }
234
+ const cur = () => tabs[tab];
235
+ const staleAll = () => { for (const tb of tabs) tb.loaded = false; };
236
+ const refresh = () => { staleAll(); loadMe(); load(cur()); };
237
+
238
+ // ---------------- writes: plan, then y ----------------
239
+ async function plan(args, title, after) {
240
+ note('plan', `${title}${t('common.colon')}${t('tui.checkingAndSimulatingOnChain')}`, 'info');
241
+ const r = await exec(args, t('tui.checking', { title }), { checking: true });
242
+ const lines = (r.log ?? []).filter((l) => !l.startsWith(t('tui.withoutYes')));
243
+ for (const l of lines) note('plan', l);
244
+ if (!r.ok) return fail(r, title);
245
+ pending = { args, title, after, planned: new Set(lines) };
246
+ draw();
247
+ }
248
+ async function confirm() {
249
+ const p = pending;
250
+ pending = null;
251
+ note('send', `${p.title}${t('common.colon')}${t('tui.confirmedHandedToBawTo')}`, 'warn');
252
+ const r = await exec([...p.args, '--yes'], t('tui.executing', { title: p.title }), { write: true });
253
+ for (const l of r.log ?? []) if (!p.planned.has(l)) note('exec', l, l.startsWith(t('tui.done')) ? 'ok' : undefined);
254
+ if (r.ok) {
255
+ const done = `${p.title}${t('common.colon')}${t('tui.done2')}`;
256
+ status = { t: done, s: 'ok' }; note('done', done, 'ok');
257
+ } else fail(r, p.title);
258
+ p.after?.();
259
+ refresh();
260
+ }
261
+ async function once(args, title) {
262
+ note('run', `${title}…`, 'info');
263
+ const r = await exec(args, `${title}…`);
264
+ for (const l of r.log ?? []) note('run', l);
265
+ if (!r.ok) fail(r, title);
266
+ refresh();
267
+ }
268
+ function ask(label) {
269
+ return new Promise((resolve) => { prompt = { label, value: '', resolve }; draw(); });
270
+ }
271
+
272
+ // ---------------- login ----------------
273
+ function stopLogin() {
274
+ login.abort?.abort();
275
+ clearTimeout(login.expiry);
276
+ clearInterval(loginTick); loginTick = null;
277
+ }
278
+ async function checkLogin() {
279
+ login = { state: 'checking' };
280
+ draw();
281
+ try {
282
+ const st = await auth.status();
283
+ if (quitting) return;
284
+ if (st === 'CONNECTED') { login = { state: st }; return refresh(); }
285
+ login = { state: st };
286
+ note('login', st === 'CREATING'
287
+ ? t('tui.bawIsCreatingTheWallet')
288
+ : t('tui.bawIsNotLoggedIn'), 'warn');
289
+ load(cur()); // public lists work without a wallet
290
+ return startLogin();
291
+ } catch (e) {
292
+ if (quitting) return;
293
+ login = { state: 'error', err: e.message };
294
+ note('login', `baw${t('common.colon')}${e.message}`, 'bad');
295
+ load(cur());
296
+ }
297
+ }
298
+ async function startLogin() {
299
+ stopLogin();
300
+ showLogin = true;
301
+ login = { state: 'UNCONNECTED', loading: true };
302
+ draw();
303
+ let s;
304
+ try { s = await auth.signin(); } catch (e) {
305
+ if (quitting) return;
306
+ login = { state: 'error', err: e.message };
307
+ return note('login', `${t('tui.couldNotCreateTheLogin')}${t('common.colon')}${e.message}`, 'bad');
308
+ }
309
+ if (quitting || !showLogin) return;
310
+ if (s?.status === 'ALREADY_CONNECTED') return afterLogin();
311
+ if (typeof s?.qrCodeId !== 'string' || typeof s?.urlForWeb !== 'string') {
312
+ login = { state: 'error', err: `${t('tui.bawAuthSigninReturnedAn')}${t('common.colon')}${JSON.stringify(s).slice(0, 200)}` };
313
+ return note('login', login.err, 'bad');
314
+ }
315
+ const ac = new AbortController();
316
+ const expireAt = Number(s.expireAt) || Date.now() + 5 * 60_000;
317
+ const l = {
318
+ state: 'UNCONNECTED', abort: ac, expireAt, pairing: s.pairingCode, url: s.urlForWeb, lines: qrLines(s.urlForWeb),
319
+ expiry: setTimeout(() => { l.expired = true; ac.abort(); }, Math.max(0, expireAt - Date.now())),
320
+ };
321
+ login = l;
322
+ loginTick = setInterval(draw, 1000);
323
+ note('login', t('tui.loginQrCodeReadyWaiting'));
324
+ try {
325
+ const v = await auth.verify(s.qrCodeId, ac.signal);
326
+ stopLogin();
327
+ if (quitting) return;
328
+ if (v?.status && v.status !== 'SUCCESS') throw Error(t('tui.scanResult', { status: v.status }));
329
+ afterLogin();
330
+ } catch (e) {
331
+ stopLogin();
332
+ if (quitting || login !== l) return; // a newer QR replaced this one
333
+ if (e.name === 'AbortError') {
334
+ login = { state: 'UNCONNECTED', expired: l.expired };
335
+ if (l.expired) note('login', t('tui.qrCodeExpiredPressL'), 'warn');
336
+ } else {
337
+ login = { state: 'error', err: e.message };
338
+ note('login', `${t('tui.loginFailed')}${t('common.colon')}${e.message}`, 'bad');
339
+ }
340
+ draw();
341
+ }
342
+ }
343
+ function afterLogin() {
344
+ login = { state: 'CONNECTED' };
345
+ showLogin = false;
346
+ note('login', t('tui.bawLoggedIn'), 'ok');
347
+ refresh();
348
+ }
349
+
350
+ // ---------------- rendering ----------------
351
+ function headerLine() {
352
+ const sep = [' │ ', 'frame'];
353
+ const segs = [['bnat', 'b'], sep];
354
+ if (me) {
355
+ segs.push([short(me.address)], sep, ['.ai '], me.agentId ? ['✓', 'ok'] : [t('tui.none'), 'bad'], sep,
356
+ [`BNB ${units(me.bnb, 6)}`], sep, [`NAT ${me.nfts}`], sep, [`BNAT ${units(me.bnat, 6)}`], sep);
357
+ if (mintTo) segs.push([t('tui.mintToHeader', { to: short(mintTo) }), 'warn'], sep);
358
+ } else if (meErr && login.state === 'CONNECTED') segs.push([`${t('tui.walletReadFailed')}${t('common.colon')}${meErr}`, 'bad'], sep);
359
+ const ls = login.state;
360
+ segs.push(ls === 'CONNECTED' ? [t('tui.bawLoggedIn2'), 'ok'] : ls === 'checking' ? [t('tui.bawCheckingLogin'), 'dim']
361
+ : ls === 'error' ? [t('tui.bawUnavailable'), 'bad'] : [t('tui.bawNotLoggedInL'), 'warn']);
362
+ return segs;
363
+ }
364
+ function statusLine() {
365
+ if (inflight.length) {
366
+ const label = executing ? `${inflight.at(-1).label}${t('tui.cannotQuitUntilItFinishes')}` : inflight.at(-1).label;
367
+ return [[`${'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'[spin % 10]} ${label}`, 'warn']];
368
+ }
369
+ if (status) return [[status.t, status.s]];
370
+ return [[t('tui.ready'), 'dim']];
371
+ }
372
+ function tabTitle() {
373
+ const segs = [];
374
+ tabs.forEach((tb, k) => {
375
+ if (k) segs.push([' │ ', 'frame']);
376
+ segs.push([`${k + 1} ${t(tb.name)}`, k === tab && !showLogin ? 'tab' : 'dim']);
377
+ });
378
+ if (showLogin) return [[t('tui.bawLogin'), 'tab'], [' ', ''], ...segs];
379
+ const tb = cur(), info = [];
380
+ if (tb.multi && tb.picked.size) {
381
+ let sum = 0n;
382
+ for (const g of tb.picked.values()) { const a = wrapAmounts(BigInt(g)); sum += tb.key === 'held' ? a.toHolder : a.unwrapCost; }
383
+ const amt = units(sum, 9);
384
+ info.push(tb.key === 'held'
385
+ ? t('tui.pickedGetBnat', { size: tb.picked.size, amt })
386
+ : t('tui.pickedPayBnat', { size: tb.picked.size, amt }));
387
+ }
388
+ if (tb.key === 'blocks' && tb.rows.length) info.push(t('tui.rankRange', { from: tb.offset + 1, to: tb.offset + tb.rows.length }));
389
+ else if (tb.rows.length && (tb.offset || (tb.total ?? 0) > tb.rows.length || tb.rows.length === PAGE)) {
390
+ const range = `${tb.offset + 1}–${tb.offset + tb.rows.length}${tb.total ? ` / ${tb.total}` : ''}`;
391
+ info.push(t('tui.pageRange', { range }));
392
+ }
393
+ if (tb.key === 'vault') info.push(tb.sort === 'burn' ? t('tui.byBurn') : t('tui.newest'));
394
+ if (tb.key === 'market' && tb.mine) info.push(t('tui.mineOnly'));
395
+ if (info.length) segs.push([` ${info.join(' · ')}`, 'info']);
396
+ return segs;
397
+ }
398
+ function columns(tb) {
399
+ const mark = (x) => (tb.picked.has(x.blk) ? ['✓ ', 'okb'] : ['· ', 'dim']);
400
+ const BLOCK = t('tui.block'), BURN = t('tui.burnGwei');
401
+ switch (tb.key) {
402
+ case 'blocks': return {
403
+ head: `${lpad(t('tui.rank'), 6)} ${pad(BLOCK, 14)}${lpad(BURN, 18)} BNB`,
404
+ row: (x, k) => [[lpad(String(tb.offset + k + 1), 6), 'dim'], [` ${pad(`#${x.blk}`, 14)}`], [lpad(group(x.burnGwei), 18)], [` ${gweiBnb(x.burnGwei)}`]],
405
+ };
406
+ case 'held': return {
407
+ head: ` ${pad(BLOCK, 14)}${lpad(BURN, 18)} ${t('tui.wrapGetsBnat')}`,
408
+ row: (x) => [mark(x), [pad(`#${x.blk}`, 14)], [lpad(group(x.burnGwei), 18)], [` ${units(wrapAmounts(BigInt(x.burnGwei)).toHolder)}`]],
409
+ };
410
+ case 'vault': return {
411
+ head: ` ${pad(BLOCK, 14)}${lpad(BURN, 18)} ${t('tui.unwrapCostsBnat')}`,
412
+ row: (x) => [mark(x), [pad(`#${x.blk}`, 14)], [lpad(group(x.burnGwei), 18)], [` ${units(wrapAmounts(BigInt(x.burnGwei)).unwrapCost)}`]],
413
+ };
414
+ case 'market': return {
415
+ head: `${pad(t('tui.id'), 7)}${pad(t('tui.priceBnbBnat'), 20)}${pad(t('tui.leftBnat'), 20)}${pad(t('tui.lockedBnb'), 20)} ${t('tui.buyer')}`,
416
+ row: (b) => {
417
+ const mine = me && b.buyer.toLowerCase() === me.address.toLowerCase();
418
+ return [[pad(`#${b.id}`, 7)], [pad(units(b.price), 20)], [pad(units(b.remaining), 20)], [pad(units(b.escrow), 20)], [` ${short(b.buyer)}`, 'dim'], ...(mine ? [[t('tui.mine'), 'info']] : [])];
419
+ },
420
+ };
421
+ default: return {
422
+ head: `${pad(t('tui.time'), 20)}${pad(t('tui.status'), 11)}${pad(t('tui.action'), 8)}${t('tui.summary')}`,
423
+ row: (e) => {
424
+ const s = e.status === 'confirmed' ? 'ok' : e.status === 'failed' ? 'bad' : e.status === 'abandoned' ? 'dim' : 'warn';
425
+ return [[pad((e.at ?? '').slice(0, 19).replace('T', ' '), 20), 'dim'], [pad(e.status, 11), s], [pad(e.action, 8)], [`${e.summary ?? ''}${e.hash ? ` ${short(e.hash)}` : ''}`]];
426
+ },
427
+ };
428
+ }
429
+ }
430
+ function tableBody(inner, h) {
431
+ if (showLogin) return loginBody(inner, h);
432
+ const tb = cur();
433
+ if (tb.err) return wrapText(`${t('tui.readFailed')}${t('common.colon')}${tb.err}`, inner).slice(0, h).map((l) => ({ segs: [[l, 'bad']] }));
434
+ const c = columns(tb);
435
+ const out = [{ segs: [[c.head, 'dim']] }];
436
+ if (!tb.rows.length) { out.push({ segs: [[tb.loaded && !inflight.length ? t('tui.nothingHere') : '', 'dim']] }); return out; }
437
+ const room = h - 1;
438
+ if (tb.i < tb.top) tb.top = tb.i;
439
+ if (tb.i >= tb.top + room) tb.top = tb.i - room + 1;
440
+ tb.top = Math.max(0, Math.min(tb.top, tb.rows.length - room));
441
+ tb.rows.slice(tb.top, tb.top + room).forEach((x, k) => {
442
+ const idx = tb.top + k;
443
+ out.push({ segs: c.row(x, idx), base: idx === tb.i ? 'sel' : undefined });
444
+ });
445
+ return out;
446
+ }
447
+ function loginBody(inner, h) {
448
+ const center = (segs) => ({ segs: [[' '.repeat(Math.max(0, Math.floor((inner - segWidth(segs)) / 2)))], ...segs] });
449
+ if (login.state === 'error') return wrapText(`${t('tui.bawUnavailable')}${t('common.colon')}${login.err}`, inner).map((l) => ({ segs: [[l, 'bad']] }));
450
+ if (!login.lines) {
451
+ return [center([[login.loading ? t('tui.creatingTheLoginQrCode')
452
+ : login.expired ? t('tui.qrCodeExpiredPressL')
453
+ : t('tui.notLoggedInPressL'), 'warn']])];
454
+ }
455
+ const left = Math.max(0, Math.round((login.expireAt - Date.now()) / 1000));
456
+ const mmss = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
457
+ const info = [
458
+ center([[t('tui.scanWithTheBinanceApp'), 'b']]),
459
+ center([[t('tui.pairingCodeCheckItMatches', { v0: login.pairing ?? '—' }), 'info']]),
460
+ center([[t('tui.expiresInLNewCode', { mmss }), 'dim']]),
461
+ ];
462
+ if (login.lines.length + info.length + 1 > h || width(login.lines[0]) > inner) {
463
+ const out = [center([[t('tui.windowTooSmallForThe'), 'warn']])];
464
+ for (const l of wrapText(login.url, inner)) out.push({ segs: [[l, 'info']] });
465
+ return [...out, ...info];
466
+ }
467
+ return [...login.lines.map((l) => center([[l, 'qr']])), { segs: [] }, ...info];
468
+ }
469
+ function box(title, body, cols, h) {
470
+ if (segWidth(title) > cols - 4) { // keep the corner: cut the last segment from the left
471
+ const [text, s] = title.at(-1), room = cols - 4 - segWidth(title.slice(0, -1)) - 1;
472
+ let cut = String(text);
473
+ while (cut && width(cut) > room) cut = [...cut].slice(1).join('');
474
+ title = [...title.slice(0, -1), [`…${cut}`, s]];
475
+ }
476
+ const tw = Math.min(segWidth(title), cols - 4);
477
+ const lines = [[['┌ ', 'frame'], ...title, [` ${'─'.repeat(Math.max(0, cols - 4 - tw))}┐`, 'frame']]];
478
+ for (let k = 0; k < h; k++) lines.push({ inner: body[k] ?? { segs: [] } });
479
+ lines.push([[`└${'─'.repeat(Math.max(0, cols - 2))}┘`, 'frame']]);
480
+ return lines;
481
+ }
482
+ function draw() {
483
+ if (quitting) return;
484
+ try { render(); } catch (e) { output.write(`\x1b[H\x1b[2J${t('tui.drawFailed')}${t('common.colon')}${e.message}\r\n`); }
485
+ }
486
+ function render() {
487
+ const { cols, rows } = dims();
488
+ const inner = cols - 2;
489
+ const errs = [...errors].sort((a, b) => b[1] - a[1]);
490
+ const errLines = errs.length
491
+ ? [[[t('tui.errorsCountReason'), 'badb']], ...errs.slice(0, 3).map(([m, n]) => [[`${lpad(String(n), 4)} · `, 'bad'], [m]])]
492
+ : [];
493
+ const tail = [];
494
+ if (pending) {
495
+ tail.push([[t('tui.toConfirm'), 'warn'], [pending.title, 'b'], [t('tui.checksAndSimulationPassedNothing'), 'warn'],
496
+ ['y', 'okb'], [t('tui.send'), 'warn'], ['n', 'badb'], [t('tui.cancel'), 'warn']]);
497
+ }
498
+ if (prompt) tail.push([[prompt.label, 'b'], [`${prompt.value}█`]]);
499
+ const keys = showLogin ? t('tui.lNewQrCodeEsc')
500
+ : t('tui.16TabsMoveR', { v0: t(KEYS[cur().key]) });
501
+ const loginKey = login.state === 'CONNECTED' || showLogin ? '' : t('tui.lLogIn');
502
+ const global = t('tui.pgupPgdnScrollLogQ', { loginKey });
503
+ if (width(`${keys} · ${global}`) <= cols) tail.push([[`${keys} · ${global}`, 'dim']]);
504
+ else tail.push([[keys, 'dim']], [[global, 'dim']]);
505
+
506
+ const room = Math.max(10, rows - 2 - errLines.length - tail.length) - 4; // minus two box frames
507
+ const tableH = showLogin ? Math.max(3, room - 4) : Math.max(3, Math.round(room * 0.6));
508
+ const logH = Math.max(1, room - tableH);
509
+
510
+ const logLines = log.flatMap((e) => wrapText(`${hhmmss(e.at)} ${e.t}`, inner).map((l, k) => ({ segs: k ? [[l, e.s]] : [[l.slice(0, 10), 'dim'], [l.slice(10), e.s]] })));
511
+ logBack = Math.max(0, Math.min(logBack, logLines.length - logH));
512
+ const logView = logLines.slice(Math.max(0, logLines.length - logH - logBack), logLines.length - logBack);
513
+ const logTitle = [[t('tui.log'), 'b'], [` ${tilde(logFile)}`, 'dim'], ...(logBack ? [[` ↑${logBack}`, 'info']] : [])];
514
+
515
+ const lines = [headerLine(), statusLine(), ...errLines,
516
+ ...box(tabTitle(), tableBody(inner, tableH), cols, tableH),
517
+ ...box(logTitle, logView, cols, logH), ...tail].slice(0, rows);
518
+ let out = '\x1b[H';
519
+ lines.forEach((l, k) => {
520
+ if (k) out += '\r\n';
521
+ out += Array.isArray(l) ? paint(l, cols) : `${ST.frame}│${RESET}${paint(l.inner.segs, inner, l.inner.base)}${ST.frame}│${RESET}`;
522
+ });
523
+ out += '\x1b[J';
524
+ output.write(out);
525
+ }
526
+
527
+ // ---------------- keys ----------------
528
+ function quit() {
529
+ if (executing) { status = { t: t('tui.aTransactionIsExecutingCannot'), s: 'warn' }; return draw(); }
530
+ quitting = true;
531
+ clearInterval(timer); timer = null;
532
+ stopLogin();
533
+ input.off('keypress', onKey);
534
+ input.setRawMode?.(false);
535
+ input.pause();
536
+ output.off?.('resize', draw);
537
+ process.off('exit', restore);
538
+ restore();
539
+ resolveQuit();
540
+ }
541
+ async function setMintTo() {
542
+ const v = await ask(t('tui.mintToWhichAddress0x'));
543
+ if (v === null) return draw();
544
+ if (v !== '' && v !== 'self' && !isAddress(v)) { status = { t: t('tui.badAddress', { v }), s: 'warn' }; return draw(); }
545
+ mintTo = v === '' || v === 'self' ? null : getAddress(v);
546
+ try { saveSetting('mintTo', mintTo ?? undefined); } catch (e) { status = { t: e.message, s: 'bad' }; return draw(); }
547
+ note('config', mintTo ? t('tui.mintToSet', { to: mintTo, file: tilde(configFile()) }) : t('tui.mintToCleared', { file: tilde(configFile()) }), 'info');
548
+ }
549
+ function switchLang() {
550
+ setLang(lang === 'zh' ? 'en' : 'zh');
551
+ try { saveSetting('lang', lang); } catch (e) { status = { t: e.message, s: 'bad' }; }
552
+ note('config', t('tui.langSwitched', { file: tilde(configFile()) }), 'info');
553
+ }
554
+ function switchTab(k) {
555
+ tab = k; status = null;
556
+ if (!cur().loaded) load(cur());
557
+ draw();
558
+ }
559
+ function page(tb, by) {
560
+ tb.offset = Math.max(0, tb.offset + by); tb.i = 0; tb.top = 0; tb.picked.clear();
561
+ return load(tb);
562
+ }
563
+ async function onListKey(k) {
564
+ const tb = cur(), x = tb.rows[tb.i];
565
+ const more = tb.total !== null ? tb.total > tb.offset + tb.rows.length : tb.rows.length === PAGE;
566
+ if (k.name === 'up') tb.i = Math.max(0, tb.i - 1);
567
+ else if (k.name === 'down') tb.i = Math.min(Math.max(0, tb.rows.length - 1), tb.i + 1);
568
+ else if (k.str === 'n' && more && !(tb.key === 'blocks' && tb.offset + PAGE >= TOP_DEPTH)) return page(tb, PAGE);
569
+ else if (k.str === 'p' && tb.offset > 0) return page(tb, -PAGE);
570
+ else if (tb.key === 'blocks' && x) {
571
+ if (k.name === 'return') {
572
+ return plan(['mint', '--block', String(x.blk)], mintTo ? t('tui.mint2', { blk: x.blk, to: short(mintTo) }) : t('tui.mint', { blk: x.blk }));
573
+ }
574
+ if (k.str === 't') return setMintTo();
575
+ } else if (tb.multi) {
576
+ if (k.name === 'space' && x) {
577
+ if (tb.picked.has(x.blk)) tb.picked.delete(x.blk);
578
+ else if (tb.picked.size < BATCH) tb.picked.set(x.blk, x.burnGwei);
579
+ else status = { t: t('tui.atMostPerTransaction', { BATCH }), s: 'warn' };
580
+ tb.i = Math.min(tb.rows.length - 1, tb.i + 1);
581
+ } else if (k.str === 'a') {
582
+ for (const r of tb.rows) if (tb.picked.size < BATCH) tb.picked.set(r.blk, r.burnGwei);
583
+ if (tb.rows.length > BATCH) status = { t: t('tui.atMostPerTransactionPicked', { BATCH }), s: 'warn' };
584
+ } else if (k.str === 's' && tb.key === 'vault') { tb.sort = tb.sort === 'burn' ? 'recent' : 'burn'; return page(tb, -tb.offset); }
585
+ else if (k.name === 'escape') tb.picked.clear();
586
+ else if (k.name === 'return') {
587
+ const blks = tb.picked.size ? [...tb.picked.keys()] : x ? [x.blk] : [];
588
+ if (!blks.length) return;
589
+ const verb = tb.key === 'held' ? 'wrap' : 'unwrap';
590
+ return plan([verb, '--block', blks.join(',')], t('tui.batchTitle', { verb, length: blks.length }), () => tb.picked.clear());
591
+ }
592
+ } else if (tb.key === 'market') {
593
+ if (k.str === 'm') { tb.mine = !tb.mine; return page(tb, -tb.offset); }
594
+ if (k.str === 'b') {
595
+ const price = await ask(t('tui.priceBnbBnatEscCancels')); if (!price) return;
596
+ const amount = await ask(t('tui.amountBnatEscCancels')); if (!amount) return;
597
+ return plan(['bid', '--price', price, '--amount', amount], t('tui.bidBnat', { amount, price }));
598
+ }
599
+ if (k.str === 'c' && x) {
600
+ if (!me || x.buyer.toLowerCase() !== me.address.toLowerCase()) { status = { t: t('tui.youCanOnlyCancelYour'), s: 'warn' }; return draw(); }
601
+ return plan(['cancel', '--bid', String(x.id)], t('tui.cancelBid', { id: x.id }));
602
+ }
603
+ if (k.name === 'return' && x) {
604
+ const amt = await ask(t('tui.howMuchBnatToSell', { id: x.id }));
605
+ if (amt === null) return;
606
+ return plan(['sell', '--bid', String(x.id), ...(amt ? ['--amount', amt] : ['--all'])], t('tui.sellToBid', { id: x.id }));
607
+ }
608
+ } else if (tb.key === 'history' && x && (k.str === 'h' || k.str === 'x')) {
609
+ if (['confirmed', 'failed', 'abandoned'].includes(x.status)) { status = { t: t('tui.thisEntryIsAlreadySettled'), s: 'dim' }; return draw(); }
610
+ if (k.str === 'h') {
611
+ const hash = await ask(t('tui.transactionHash0xFindIt'));
612
+ if (hash) return once(['resume', '--hash', hash, '--id', x.id], t('tui.settle', { action: x.action }));
613
+ } else {
614
+ const sure = await ask(t('tui.sureItWasNeverSent'));
615
+ if (sure === 'yes') return once(['resume', '--abandon', x.id], t('tui.abandon', { action: x.action }));
616
+ }
617
+ }
618
+ draw();
619
+ }
620
+ function onKey(str, key = {}) {
621
+ const k = { ...key, str };
622
+ if (k.ctrl && k.name === 'c') return quit();
623
+ if (prompt) {
624
+ const p = prompt;
625
+ if (k.name === 'return') { prompt = null; p.resolve(p.value.trim()); }
626
+ else if (k.name === 'escape') { prompt = null; p.resolve(null); }
627
+ else if (k.name === 'backspace') p.value = [...p.value].slice(0, -1).join('');
628
+ else if (str && !k.ctrl && !k.meta && str >= ' ') p.value += str;
629
+ return draw();
630
+ }
631
+ if (k.name === 'pageup') { logBack += 5; return draw(); }
632
+ if (k.name === 'pagedown') { logBack = Math.max(0, logBack - 5); return draw(); }
633
+ if (k.str === 'q') return quit();
634
+ if (executing) return;
635
+ if (pending) {
636
+ if (k.str === 'y') return confirm();
637
+ if (k.str === 'n' || k.name === 'escape') { note('plan', `${pending.title}${t('common.colon')}${t('tui.cancelledNothingSent')}`, 'dim'); pending = null; return draw(); }
638
+ return; // a plan waits for y or n; nothing else runs meanwhile
639
+ }
640
+ if (inflight.some((j) => j.checking)) return; // a plan is being checked
641
+ if (k.str === 'L' || k.str === 'l') { if (login.state !== 'CONNECTED') startLogin(); return; }
642
+ if (k.str === 'i') return switchLang();
643
+ if (showLogin) {
644
+ if (k.name === 'escape') {
645
+ stopLogin(); showLogin = false;
646
+ login = { state: 'UNCONNECTED' };
647
+ note('login', t('tui.skippedLoginYouCanBrowse'), 'dim');
648
+ }
649
+ return;
650
+ }
651
+ if (/^[1-9]$/.test(k.str ?? '') && Number(k.str) <= TABS.length) return switchTab(Number(k.str) - 1);
652
+ if (k.name === 'tab' || k.name === 'right') return switchTab((tab + (k.shift ? TABS.length - 1 : 1)) % TABS.length);
653
+ if (k.name === 'left') return switchTab((tab + TABS.length - 1) % TABS.length);
654
+ if (k.str === 'r') return refresh();
655
+ status = null;
656
+ onListKey(k);
657
+ }
658
+
659
+ // Leave the terminal usable however the process ends (a crash included).
660
+ function restore() { input.setRawMode?.(false); output.write(`${RESET}\x1b[?25h\x1b[?1049l`); }
661
+ process.on('exit', restore);
662
+ emitKeypressEvents(input);
663
+ input.setRawMode?.(true);
664
+ input.resume();
665
+ input.on('keypress', onKey);
666
+ output.on?.('resize', draw);
667
+ output.write('\x1b[?1049h\x1b[?25l\x1b[2J');
668
+ note('start', t('tui.bnatStarted'));
669
+ checkLogin();
670
+ });
671
+ }