@neuraiproject/neurai-depin-terminal 1.0.1 → 2.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/src/utils.js CHANGED
@@ -75,10 +75,10 @@ export function truncate(str, length, suffix = '...') {
75
75
  */
76
76
  export function formatTimestamp(timestamp, timezone = 'UTC') {
77
77
  const date = timestamp instanceof Date ? timestamp : new Date(timestamp * 1000);
78
-
78
+
79
79
  // Handle UTC explicitly
80
80
  if (timezone === 'UTC') {
81
- return date.toLocaleTimeString('en-US', {
81
+ return date.toLocaleTimeString('en-US', {
82
82
  timeZone: 'UTC',
83
83
  hour12: false,
84
84
  hour: '2-digit',
@@ -93,7 +93,7 @@ export function formatTimestamp(timestamp, timezone = 'UTC') {
93
93
  // Create a new date shifted by the offset hours
94
94
  // We use UTC for display to avoid local system timezone interference
95
95
  const shiftedDate = new Date(date.getTime() + (offset * 60 * 60 * 1000));
96
- return shiftedDate.toLocaleTimeString('en-US', {
96
+ return shiftedDate.toLocaleTimeString('en-US', {
97
97
  timeZone: 'UTC',
98
98
  hour12: false,
99
99
  hour: '2-digit',
@@ -103,7 +103,7 @@ export function formatTimestamp(timestamp, timezone = 'UTC') {
103
103
  }
104
104
 
105
105
  // Fallback to UTC
106
- return date.toLocaleTimeString('en-US', {
106
+ return date.toLocaleTimeString('en-US', {
107
107
  timeZone: 'UTC',
108
108
  hour12: false,
109
109
  hour: '2-digit',
@@ -123,53 +123,260 @@ export function createMessageKey(hash, signature) {
123
123
  }
124
124
 
125
125
  /**
126
- * Read password from stdin with character masking
126
+ * Drain stdin until silence is detected
127
+ * @param {Object} stdin - Stdin stream
128
+ * @param {number} silenceMs - Milliseconds of silence to wait for (default: 300)
129
+ * @param {number} maxWaitMs - Maximum time to wait in total (default: 2000)
130
+ * @returns {Promise<void>}
131
+ */
132
+ export async function drainInput(stdin, silenceMs = 300, maxWaitMs = 2000) {
133
+ // If not TTY, we can't really drain in the same way, but we can try small read
134
+ if (!stdin.isTTY) {
135
+ if (stdin.readableLength > 0) stdin.read();
136
+ return;
137
+ }
138
+
139
+ // Enable raw mode to catch all chars
140
+ try {
141
+ stdin.setRawMode(true);
142
+ } catch (e) {
143
+ // Ignore
144
+ }
145
+
146
+ await new Promise(resolve => {
147
+ stdin.resume();
148
+
149
+ let silenceTimer;
150
+ let maxTimer;
151
+
152
+ const cleanup = () => {
153
+ if (silenceTimer) clearTimeout(silenceTimer);
154
+ if (maxTimer) clearTimeout(maxTimer);
155
+ stdin.removeAllListeners('data');
156
+
157
+ // Pause so we don't eat future input intended for others
158
+ stdin.pause();
159
+
160
+ try {
161
+ stdin.setRawMode(false);
162
+ } catch (e) {
163
+ // Ignore
164
+ }
165
+ resolve();
166
+ };
167
+
168
+ const resetSilenceTimer = () => {
169
+ if (silenceTimer) clearTimeout(silenceTimer);
170
+ silenceTimer = setTimeout(cleanup, silenceMs);
171
+ };
172
+
173
+ // If we hit the max wait time, just force proceed
174
+ maxTimer = setTimeout(cleanup, maxWaitMs);
175
+
176
+ // Listen for ANY data and drain it
177
+ stdin.on('data', () => {
178
+ resetSilenceTimer();
179
+ });
180
+
181
+ // Start the initial timer
182
+ resetSilenceTimer();
183
+ });
184
+
185
+ // Final sanity check drain
186
+ if (stdin.readableLength > 0) {
187
+ stdin.read();
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Read password from stdin with character masking (pure implementation without readline)
127
193
  * @param {string} prompt - Prompt to display
128
194
  * @param {string} [maskChar='*'] - Character to display for each typed character
129
195
  * @returns {Promise<string>} The entered password
130
196
  */
131
- export function readPassword(prompt, maskChar = '*') {
132
- return new Promise((resolve) => {
133
- process.stdout.write(prompt);
134
- const stdin = process.stdin;
135
- stdin.setRawMode(true);
197
+ export async function readPassword(prompt, maskChar = '*') {
198
+ const stdin = process.stdin;
199
+
200
+ // Use robust drain before starting
201
+ await drainInput(stdin, 200, 1000);
202
+
203
+ return new Promise((resolve, reject) => {
204
+ let onDataHandler = null;
205
+
206
+ // Comprehensive cleanup function to ensure stdin is in pristine state
207
+ const cleanup = (removeListener = true) => {
208
+ if (removeListener && onDataHandler) {
209
+ stdin.removeListener('data', onDataHandler);
210
+ onDataHandler = null;
211
+ }
212
+
213
+ // Remove ALL listeners to avoid any residual state
214
+ stdin.removeAllListeners('data');
215
+
216
+ if (stdin.isTTY) {
217
+ try {
218
+ stdin.setRawMode(false);
219
+ } catch (error) {
220
+ // Ignore raw mode reset failures
221
+ }
222
+ }
223
+
224
+ // DON'T pause stdin - leave it ready for next use
225
+ // CharsmUI will manage its own resume/pause
226
+ };
227
+
228
+ // Ensure stdin is in correct initial state
229
+ if (!stdin.isTTY) {
230
+ reject(new Error('stdin is not a TTY'));
231
+ return;
232
+ }
233
+
234
+ // Start fresh
235
+ stdin.removeAllListeners('data');
236
+ stdin.removeAllListeners('keypress');
136
237
  stdin.resume();
238
+
239
+ // Set raw mode for character-by-character input
240
+ try {
241
+ stdin.setRawMode(true);
242
+ } catch (error) {
243
+ reject(new Error(`Failed to set raw mode: ${error.message}`));
244
+ return;
245
+ }
246
+
137
247
  stdin.setEncoding('utf8');
248
+
249
+ // Always mask password (show asterisks)
250
+ const shouldMask = true;
138
251
  let password = '';
252
+ let done = false;
253
+ let escapeState = 'normal';
139
254
 
140
- const onData = (char) => {
141
- switch (char) {
142
- case KEY_CODES.ENTER:
143
- case KEY_CODES.CARRIAGE_RETURN:
144
- case KEY_CODES.CTRL_D:
145
- stdin.setRawMode(false);
146
- stdin.pause();
147
- stdin.removeListener('data', onData);
148
- process.stdout.write('\n');
149
- resolve(password);
150
- break;
255
+ const finish = () => {
256
+ if (done) {
257
+ return;
258
+ }
259
+ done = true;
260
+ cleanup(true);
261
+ process.stdout.write('\n');
262
+ resolve(password);
263
+ };
151
264
 
152
- case KEY_CODES.CTRL_C:
153
- process.stdout.write('\n');
154
- process.exit(0);
265
+ onDataHandler = (chunk) => {
266
+ for (const ch of chunk) {
267
+ if (done) {
155
268
  break;
269
+ }
270
+
271
+ const codePoint = ch.charCodeAt(0);
156
272
 
157
- case KEY_CODES.BACKSPACE:
158
- case KEY_CODES.BACKSPACE_ALT:
159
- if (password.length > 0) {
160
- password = password.slice(0, -1);
161
- process.stdout.write(TERMINAL.BACKSPACE);
273
+ // State machine for filtering ANSI escape sequences
274
+ if (escapeState === 'esc') {
275
+ if (ch === '[') {
276
+ escapeState = 'csi';
277
+ } else if (ch === ']') {
278
+ escapeState = 'osc';
279
+ } else {
280
+ escapeState = 'normal';
162
281
  }
163
- break;
282
+ continue;
283
+ }
164
284
 
165
- default:
166
- password += char;
167
- process.stdout.write(maskChar);
168
- break;
285
+ if (escapeState === 'csi') {
286
+ if (ch >= '@' && ch <= '~') {
287
+ escapeState = 'normal';
288
+ }
289
+ continue;
290
+ }
291
+
292
+ if (escapeState === 'osc') {
293
+ if (ch === '\x07') {
294
+ escapeState = 'normal';
295
+ } else if (codePoint === 0x9c) {
296
+ escapeState = 'normal';
297
+ } else if (ch === '\x1b') {
298
+ escapeState = 'osc-esc';
299
+ }
300
+ continue;
301
+ }
302
+
303
+ if (escapeState === 'osc-esc') {
304
+ if (ch === '\\') {
305
+ escapeState = 'normal';
306
+ } else if (ch !== '\x1b') {
307
+ escapeState = 'osc';
308
+ }
309
+ continue;
310
+ }
311
+
312
+ // Process actual characters
313
+ switch (ch) {
314
+ case KEY_CODES.ENTER:
315
+ case KEY_CODES.CARRIAGE_RETURN:
316
+ case KEY_CODES.CTRL_D:
317
+ finish();
318
+ return;
319
+
320
+ case KEY_CODES.CTRL_C:
321
+ cleanup(true);
322
+ process.stdout.write('\n');
323
+ resetTerminal();
324
+ process.exit(0);
325
+
326
+ case KEY_CODES.BACKSPACE:
327
+ case KEY_CODES.BACKSPACE_ALT:
328
+ if (password.length > 0) {
329
+ password = password.slice(0, -1);
330
+ if (shouldMask) {
331
+ process.stdout.write(TERMINAL.BACKSPACE);
332
+ }
333
+ }
334
+ break;
335
+
336
+ default:
337
+ // Start of escape sequence
338
+ if (ch === '\x1b') {
339
+ escapeState = 'esc';
340
+ break;
341
+ }
342
+
343
+ // C1 control characters (ignore)
344
+ if (codePoint === 0x9b) {
345
+ escapeState = 'csi';
346
+ break;
347
+ }
348
+ if (codePoint === 0x9d) {
349
+ escapeState = 'osc';
350
+ break;
351
+ }
352
+ if (codePoint >= 0x80 && codePoint <= 0x9f) {
353
+ // Ignore C1 control characters
354
+ break;
355
+ }
356
+
357
+ // C0 control characters (ignore except Enter, Backspace, Ctrl+C handled above)
358
+ if (codePoint < 0x20) {
359
+ // Ignore C0 control characters
360
+ break;
361
+ }
362
+
363
+ // Valid printable character
364
+ password += ch;
365
+ if (shouldMask) {
366
+ process.stdout.write(maskChar);
367
+ }
368
+ break;
369
+ }
169
370
  }
170
371
  };
171
372
 
172
- stdin.on('data', onData);
373
+ // Attach listener FIRST so it can filter any incoming ANSI sequences
374
+ stdin.on('data', onDataHandler);
375
+
376
+ // Show prompt after a tiny delay
377
+ setTimeout(() => {
378
+ process.stdout.write(prompt);
379
+ }, 10);
173
380
  });
174
381
  }
175
382
 
@@ -186,12 +393,12 @@ export async function withSuppressedConsole(fn) {
186
393
  const originalStdoutWrite = process.stdout.write;
187
394
  const originalStderrWrite = process.stderr.write;
188
395
 
189
- console.log = () => {};
190
- console.warn = () => {};
191
- console.error = () => {};
192
- console.info = () => {};
193
- process.stdout.write = () => {};
194
- process.stderr.write = () => {};
396
+ console.log = () => { };
397
+ console.warn = () => { };
398
+ console.error = () => { };
399
+ console.info = () => { };
400
+ process.stdout.write = () => { };
401
+ process.stderr.write = () => { };
195
402
 
196
403
  try {
197
404
  return await fn();
@@ -213,6 +420,12 @@ export function resetTerminal() {
213
420
  if (process.stdout.isTTY) {
214
421
  try {
215
422
  process.stdout.write(TERMINAL.EXIT_ALT_SCREEN);
423
+ // Disable mouse reporting (1000, 1002, 1003, 1006, 1015)
424
+ process.stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l');
425
+ // Disable bracketed paste (2004)
426
+ process.stdout.write('\x1b[?2004l');
427
+ // Disable focus tracking (1004)
428
+ process.stdout.write('\x1b[?1004l');
216
429
  process.stdout.write(TERMINAL.SHOW_CURSOR);
217
430
  process.stdout.write(TERMINAL.RESET_ATTRIBUTES);
218
431
  process.stdout.write(TERMINAL.NEW_LINE);
@@ -231,6 +444,54 @@ export function resetTerminal() {
231
444
  }
232
445
  }
233
446
 
447
+ /**
448
+ * Emergency terminal cleanup
449
+ * Called at startup to ensure terminal is in a clean state
450
+ */
451
+ export function emergencyTerminalCleanup() {
452
+ if (process.stdin.isTTY) {
453
+ try {
454
+ // Remove any stale listeners
455
+ process.stdin.removeAllListeners('keypress');
456
+ process.stdin.removeAllListeners('data');
457
+
458
+ // Ensure raw mode is off
459
+ process.stdin.setRawMode(false);
460
+
461
+ // Resume stdin to allow reading buffered data
462
+ process.stdin.resume();
463
+
464
+ // Aggressively flush all buffered data (may need multiple reads)
465
+ let flushed = 0;
466
+ while (process.stdin.readableLength > 0 && flushed < 10) {
467
+ process.stdin.read();
468
+ flushed++;
469
+ }
470
+
471
+ // Now pause for normal operation
472
+ process.stdin.pause();
473
+ } catch (err) {
474
+ // Ignore errors during cleanup
475
+ }
476
+ }
477
+
478
+ if (process.stdout.isTTY) {
479
+ try {
480
+ // Reset terminal attributes
481
+ process.stdout.write(TERMINAL.RESET_ATTRIBUTES);
482
+ // Disable mouse reporting (1000, 1002, 1003, 1006, 1015)
483
+ process.stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l');
484
+ // Disable bracketed paste (2004)
485
+ process.stdout.write('\x1b[?2004l');
486
+ // Disable focus tracking (1004)
487
+ process.stdout.write('\x1b[?1004l');
488
+ process.stdout.write(TERMINAL.SHOW_CURSOR);
489
+ } catch (err) {
490
+ // Ignore errors
491
+ }
492
+ }
493
+ }
494
+
234
495
  /**
235
496
  * Parse RPC host from URL for display
236
497
  * @param {string} url - Full RPC URL
@@ -277,8 +538,8 @@ export function validatePassword(password, minLength, maxLength) {
277
538
  */
278
539
  export function isPubkeyRevealed(pubkeyResponse) {
279
540
  return pubkeyResponse?.pubkey &&
280
- pubkeyResponse?.revealed === 1 &&
281
- pubkeyResponse.pubkey.trim().length > 0;
541
+ pubkeyResponse?.revealed === 1 &&
542
+ pubkeyResponse.pubkey.trim().length > 0;
282
543
  }
283
544
 
284
545
  /**
@@ -1,272 +0,0 @@
1
- /**
2
- * Terminal UI for Neurai DePIN Terminal
3
- * Full-screen blessed-based interface with top bar, message area, input, and status bar
4
- * @module TerminalUI
5
- */
6
-
7
- import blessed from 'blessed';
8
- import {
9
- COLORS,
10
- ICONS,
11
- BLESSED_KEYS,
12
- ADDRESS,
13
- PRIVACY,
14
- TIME
15
- } from '../constants.js';
16
- import { formatTimestamp, resetTerminal } from '../utils.js';
17
- import { TopBar } from './components/TopBar.js';
18
- import { MessageBox } from './components/MessageBox.js';
19
- import { InputBox } from './components/InputBox.js';
20
- import { StatusBar } from './components/StatusBar.js';
21
- import { ErrorOverlay } from './components/ErrorOverlay.js';
22
-
23
- /**
24
- * Terminal UI manager using blessed library
25
- */
26
- export class TerminalUI {
27
- /**
28
- * Create a new TerminalUI instance
29
- * @param {Object} config - Configuration object
30
- * @param {string} config.rpc_url - RPC server URL
31
- * @param {string} config.token - DePIN token name
32
- * @param {WalletManager} walletManager - Wallet manager instance
33
- * @param {RpcService} rpcService - RPC service instance
34
- */
35
- constructor(config, walletManager, rpcService) {
36
- this.config = config;
37
- this.walletManager = walletManager;
38
- this.rpcService = rpcService;
39
- this.myAddress = walletManager.getAddress();
40
- this.sendCallback = null;
41
- this.displayedMessages = [];
42
- this.hasPrivacy = false;
43
- this.encryptionType = PRIVACY.DEFAULT_ENCRYPTION;
44
- this.totalMessages = 0;
45
- this.lastConnectionStatus = false;
46
- this.lastPollTime = null;
47
-
48
- this.initializeScreen();
49
- this.createComponents();
50
- this.setupKeybindings();
51
- this.inputBox.focus();
52
- this.screen.render();
53
- this.updateTopBar({ connected: false, lastPoll: null });
54
- }
55
-
56
- /**
57
- * Initialize blessed screen
58
- */
59
- initializeScreen() {
60
- this.screen = blessed.screen({
61
- smartCSR: true,
62
- title: 'Neurai DePIN Terminal'
63
- });
64
- }
65
-
66
- /**
67
- * Create UI components (top bar, message box, input box, status bar)
68
- */
69
- createComponents() {
70
- this.topBar = new TopBar(this.screen, this.config, this.myAddress);
71
- this.messageBox = new MessageBox(this.screen);
72
- this.inputBox = new InputBox(this.screen, (msg) => this.handleSend(msg));
73
- this.statusBar = new StatusBar(this.screen);
74
- this.errorOverlay = new ErrorOverlay(this.screen);
75
- }
76
-
77
- /**
78
- * Show blocking error overlay
79
- * @param {string[]} errors - List of error messages
80
- */
81
- showBlockingErrors(errors) {
82
- this.inputBox.disable();
83
- this.errorOverlay.show(errors);
84
- }
85
-
86
- /**
87
- * Clear blocking error overlay
88
- */
89
- clearBlockingErrors() {
90
- if (this.errorOverlay.isVisible()) {
91
- this.errorOverlay.hide();
92
- this.inputBox.enable();
93
- this.inputBox.focus();
94
- }
95
- }
96
-
97
- /**
98
- * Setup keyboard bindings
99
- */
100
- setupKeybindings() {
101
- // Exit on Ctrl+C or Escape
102
- this.screen.key(BLESSED_KEYS.QUIT, () => {
103
- this.cleanup();
104
- process.exit(0);
105
- });
106
- }
107
-
108
- /**
109
- * Update top bar with connection status and info
110
- * @param {Object} status - Status object
111
- * @param {boolean} status.connected - Connection status
112
- * @param {Date|null} status.lastPoll - Last poll time
113
- */
114
- updateTopBar(status) {
115
- this.lastConnectionStatus = status.connected;
116
-
117
- if (status.lastPoll) {
118
- this.lastPollTime = status.lastPoll;
119
- }
120
-
121
- this.topBar.update({
122
- connected: status.connected,
123
- lastPoll: status.lastPoll
124
- });
125
- }
126
-
127
- /**
128
- * Format message line for display
129
- * @param {Object} msg - Message object
130
- * @returns {string} Formatted message line with blessed tags
131
- */
132
- formatMessageLine(msg) {
133
- const time = formatTimestamp(msg.timestamp, this.config.timezone);
134
- const isMe = msg.sender === this.myAddress;
135
- const senderLabel = isMe ? 'YOU' : msg.sender.slice(0, ADDRESS.TRUNCATE_LENGTH);
136
- const color = isMe ? COLORS.MY_MESSAGE : COLORS.OTHER_MESSAGE;
137
-
138
- return `{${color}}[${time}] ${senderLabel}: ${msg.message}{/}`;
139
- }
140
-
141
- /**
142
- * Add a new message to the display
143
- * Maintains chronological order (oldest to newest)
144
- * @param {Object} msg - Message object
145
- * @param {string} msg.sender - Sender address
146
- * @param {string} msg.message - Message content
147
- * @param {number} msg.timestamp - Unix timestamp in seconds
148
- * @param {string} msg.hash - Message hash
149
- */
150
- addMessage(msg) {
151
- this.displayedMessages.push(msg);
152
-
153
- // Sort by timestamp (oldest to newest)
154
- this.displayedMessages.sort((a, b) => a.timestamp - b.timestamp);
155
-
156
- // Redraw all messages
157
- this.redrawMessages();
158
- }
159
-
160
- /**
161
- * Redraw all messages in the message box
162
- */
163
- redrawMessages() {
164
- const formattedMessages = this.displayedMessages.map(msg => this.formatMessageLine(msg));
165
- // Clear and rebuild message box content
166
- this.messageBox.component.setContent(formattedMessages.join('\n'));
167
- this.messageBox.component.setScrollPerc(100);
168
- this.screen.render();
169
- }
170
-
171
- /**
172
- * Show error message in message box
173
- * @param {string} errorMsg - Error message
174
- */
175
- showError(errorMsg) {
176
- const line = `{${COLORS.ERROR}}[ERROR] ${errorMsg}{/}`;
177
- this.messageBox.addMessage(line);
178
- }
179
-
180
- /**
181
- * Show info message in message box
182
- * @param {string} infoMsg - Info message
183
- */
184
- showInfo(infoMsg) {
185
- const line = `{${COLORS.INFO}}[INFO] ${infoMsg}{/}`;
186
- this.messageBox.addMessage(line);
187
- }
188
-
189
- /**
190
- * Show success message in message box
191
- * @param {string} successMsg - Success message
192
- */
193
- showSuccess(successMsg) {
194
- const line = `{${COLORS.SUCCESS}}[${ICONS.SUCCESS}] ${successMsg}{/}`;
195
- this.messageBox.addMessage(line);
196
- }
197
-
198
- /**
199
- * Update status bar with send status
200
- * @param {string} message - Status message
201
- * @param {string} [type='info'] - Message type: 'success', 'error', or 'info'
202
- */
203
- updateSendStatus(message, type = 'info') {
204
- this.statusBar.update(message, type);
205
- }
206
-
207
- /**
208
- * Clear status bar
209
- */
210
- clearSendStatus() {
211
- this.statusBar.update('');
212
- }
213
-
214
- /**
215
- * Handle send message action
216
- * @param {string} message - Message to send
217
- */
218
- handleSend(message) {
219
- if (!message) return;
220
-
221
- if (this.sendCallback) {
222
- this.sendCallback(message);
223
- }
224
- }
225
-
226
- /**
227
- * Register callback for send action
228
- * @param {Function} callback - Callback function(message)
229
- */
230
- onSend(callback) {
231
- this.sendCallback = callback;
232
- }
233
-
234
- /**
235
- * Update pool information from depingetmsginfo RPC call
236
- * @param {Object} poolInfo - Pool information
237
- * @param {number} [poolInfo.messages] - Total messages in pool
238
- * @param {string} [poolInfo.cipher] - Encryption cipher name
239
- * @param {string} [poolInfo.depinpoolpkey] - Server privacy public key
240
- */
241
- updatePoolInfo(poolInfo) {
242
- if (poolInfo) {
243
- this.totalMessages = poolInfo.messages || 0;
244
- this.encryptionType = poolInfo.cipher || PRIVACY.DEFAULT_ENCRYPTION;
245
- this.hasPrivacy = poolInfo.depinpoolpkey && poolInfo.depinpoolpkey !== PRIVACY.NO_KEY_VALUE;
246
-
247
- this.topBar.setTotalMessages(this.totalMessages);
248
- this.topBar.setEncryptionType(this.encryptionType);
249
-
250
- // Refresh top bar
251
- this.updateTopBar({
252
- connected: this.lastConnectionStatus,
253
- lastPoll: this.lastPollTime
254
- });
255
- }
256
- }
257
-
258
- /**
259
- * Cleanup terminal state
260
- * Removes listeners, destroys screen, resets terminal
261
- */
262
- cleanup() {
263
- if (this.screen) {
264
- try {
265
- this.screen.destroy();
266
- } catch (err) {
267
- // Ignore cleanup errors
268
- }
269
- resetTerminal();
270
- }
271
- }
272
- }