@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.
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Charsm-based Terminal UI for Neurai DePIN Terminal
3
+ * @module CharsmUI
4
+ */
5
+
6
+ import readline from 'node:readline';
7
+ import { initLip, Lipgloss } from 'charsm';
8
+ import { PRIVACY, TERMINAL } from '../constants.js';
9
+ import { TabManager } from './TabManager.js';
10
+ import { RecipientSelector } from './RecipientSelector.js';
11
+ import {
12
+ formatMessageLine,
13
+ padLine,
14
+ renderHeaderLines,
15
+ renderInputLine,
16
+ renderRecipientOverlay,
17
+ renderStatusLine,
18
+ renderTabLines
19
+ } from './render.js';
20
+ import { resetTerminal } from '../utils.js';
21
+ import { MESSAGE_TYPES, normalizeMessageType } from '../domain/messageTypes.js';
22
+
23
+ const ANSI = {
24
+ CLEAR: '\x1b[2J',
25
+ HOME: '\x1b[H',
26
+ HIDE_CURSOR: '\x1b[?25l',
27
+ SHOW_CURSOR: '\x1b[?25h',
28
+ RESET: '\x1b[0m'
29
+ };
30
+
31
+
32
+ export class CharsmUI {
33
+ static async create(config, walletManager, rpcService) {
34
+ try {
35
+ // Reduce timeout to 500ms to avoid blocking startup
36
+ await Promise.race([
37
+ initLip(),
38
+ new Promise((resolve) => setTimeout(resolve, 500))
39
+ ]);
40
+ } catch (error) {
41
+ // If WASM init stalls or fails, proceed with basic rendering (no styles)
42
+ console.warn(`Charsm init failed: ${error.message}`);
43
+ }
44
+ const ui = new CharsmUI(config, walletManager, rpcService);
45
+ ui.initialize();
46
+ return ui;
47
+ }
48
+
49
+ constructor(config, walletManager, rpcService) {
50
+ this.config = config;
51
+ this.walletManager = walletManager;
52
+ this.rpcService = rpcService;
53
+ this.myAddress = walletManager.getAddress();
54
+
55
+ this.lip = new Lipgloss();
56
+ this.stylesInitialized = false;
57
+
58
+ this.displayedMessages = [];
59
+ this.tabManager = new TabManager();
60
+
61
+ this.recipientProvider = null;
62
+ this.recipientCacheProvider = null;
63
+ this.recipientSelector = new RecipientSelector();
64
+
65
+ this.inputValue = '';
66
+ this.scrollOffset = 0;
67
+ this.statusMessage = '';
68
+ this.statusType = 'info';
69
+ this.blockingErrors = [];
70
+ this.inputDisabled = false;
71
+
72
+ this.totalMessages = 0;
73
+ this.messageExpiryHours = 0;
74
+ this.encryptionType = PRIVACY.DEFAULT_ENCRYPTION;
75
+ this.lastConnectionStatus = false;
76
+ this.lastPollTime = null;
77
+
78
+ this.sendCallback = null;
79
+ this.keypressHandler = null;
80
+ this.resizeHandler = null;
81
+ this.keypressEventsInitialized = false;
82
+
83
+ // Rendering optimization
84
+ this.renderScheduled = false;
85
+ this.renderImmediate = false;
86
+ }
87
+
88
+ initialize() {
89
+ this.createStyles();
90
+ this.tabManager.initialize();
91
+ this.setupInput();
92
+ this.render();
93
+ }
94
+
95
+ createStyles() {
96
+ if (this.stylesInitialized) {
97
+ return;
98
+ }
99
+
100
+ try {
101
+ this.lip.createStyle({
102
+ id: 'header',
103
+ canvasColor: { color: '#ffffff', background: '#1d4ed8' },
104
+ bold: true
105
+ });
106
+ this.lip.createStyle({
107
+ id: 'tabActive',
108
+ canvasColor: { color: '#22c55e' },
109
+ bold: true
110
+ });
111
+ this.lip.createStyle({
112
+ id: 'tabInactive',
113
+ canvasColor: { color: '#94a3b8' }
114
+ });
115
+ this.lip.createStyle({
116
+ id: 'msgMe',
117
+ canvasColor: { color: '#22d3ee' }
118
+ });
119
+ this.lip.createStyle({
120
+ id: 'msgOther',
121
+ canvasColor: { color: '#22c55e' }
122
+ });
123
+ this.lip.createStyle({
124
+ id: 'msgInfo',
125
+ canvasColor: { color: '#facc15' }
126
+ });
127
+ this.lip.createStyle({
128
+ id: 'msgSuccess',
129
+ canvasColor: { color: '#22c55e' }
130
+ });
131
+ this.lip.createStyle({
132
+ id: 'msgError',
133
+ canvasColor: { color: '#ef4444' }
134
+ });
135
+ this.lip.createStyle({
136
+ id: 'statusInfo',
137
+ canvasColor: { color: '#facc15' }
138
+ });
139
+ this.lip.createStyle({
140
+ id: 'statusSuccess',
141
+ canvasColor: { color: '#22c55e' }
142
+ });
143
+ this.lip.createStyle({
144
+ id: 'statusError',
145
+ canvasColor: { color: '#ef4444' }
146
+ });
147
+ } catch (error) {
148
+ // If styles cannot be created (init failure), render without styles
149
+ }
150
+
151
+ this.stylesInitialized = true;
152
+ }
153
+
154
+ applyStyle(value, id) {
155
+ if (!this.stylesInitialized || !id) {
156
+ return value;
157
+ }
158
+
159
+ try {
160
+ return this.lip.apply({ value, id });
161
+ } catch (error) {
162
+ return value;
163
+ }
164
+ }
165
+
166
+ setupInput() {
167
+ // Only call emitKeypressEvents once to prevent duplicate listeners
168
+ if (!this.keypressEventsInitialized) {
169
+ readline.emitKeypressEvents(process.stdin);
170
+ this.keypressEventsInitialized = true;
171
+ }
172
+
173
+ if (process.stdin.isTTY) {
174
+ process.stdin.setEncoding('utf8');
175
+ process.stdin.setRawMode(true);
176
+ }
177
+
178
+ this.keypressHandler = (str, key) => this.handleKeypress(str, key);
179
+ process.stdin.on('keypress', this.keypressHandler);
180
+ this.resizeHandler = () => this.scheduleRender();
181
+ process.stdout.on('resize', this.resizeHandler);
182
+ process.stdin.resume();
183
+ if (process.stdout.isTTY) {
184
+ process.stdout.write(`${TERMINAL.ENTER_ALT_SCREEN}${ANSI.CLEAR}${ANSI.HOME}`);
185
+ }
186
+ process.stdout.write(ANSI.HIDE_CURSOR);
187
+ }
188
+
189
+ cleanup() {
190
+ if (this.keypressHandler) {
191
+ process.stdin.off('keypress', this.keypressHandler);
192
+ this.keypressHandler = null;
193
+ }
194
+ process.stdin.removeAllListeners('keypress'); // Remove keypress specifically
195
+ if (this.resizeHandler) {
196
+ process.stdout.off('resize', this.resizeHandler);
197
+ this.resizeHandler = null;
198
+ }
199
+
200
+ // Use the comprehensive reset function from utils
201
+ resetTerminal();
202
+ }
203
+
204
+ setRecipientProvider(provider, cacheProvider = null) {
205
+ this.recipientProvider = provider;
206
+ this.recipientCacheProvider = cacheProvider;
207
+ }
208
+
209
+ getActivePeerAddress() {
210
+ return this.tabManager.getActivePeerAddress();
211
+ }
212
+
213
+ setActiveTab(tabId) {
214
+ if (!this.tabManager.setActiveTab(tabId)) {
215
+ return;
216
+ }
217
+ this.scheduleRender();
218
+ }
219
+
220
+ activateNextTab() {
221
+ if (!this.tabManager.activateNextTab()) {
222
+ return;
223
+ }
224
+ this.scheduleRender();
225
+ }
226
+
227
+ activatePrevTab() {
228
+ if (!this.tabManager.activatePrevTab()) {
229
+ return;
230
+ }
231
+ this.scheduleRender();
232
+ }
233
+
234
+ closeActiveTab() {
235
+ if (!this.tabManager.closeActiveTab()) {
236
+ return;
237
+ }
238
+ this.scheduleRender();
239
+ }
240
+
241
+ openPrivateTab(address, activate = false, timestamp = null) {
242
+ const tab = this.tabManager.openPrivateTab(address, activate, timestamp);
243
+ if (tab) {
244
+ this.scheduleRender();
245
+ }
246
+ return tab;
247
+ }
248
+
249
+ async loadRecipientList() {
250
+ if (!this.recipientProvider) {
251
+ return [];
252
+ }
253
+ return this.recipientProvider();
254
+ }
255
+
256
+ async openRecipientSelector() {
257
+ if (!this.recipientProvider || this.recipientSelector.isOpen()) {
258
+ return;
259
+ }
260
+
261
+ const cached = this.recipientCacheProvider ? this.recipientCacheProvider() : [];
262
+ await this.recipientSelector.openSelector({
263
+ cachedItems: cached,
264
+ loadItems: () => this.loadRecipientList(),
265
+ onUpdate: () => this.scheduleRender(),
266
+ onError: (error) => {
267
+ this.updateSendStatus(`Failed to load recipients: ${error.message}`, 'error');
268
+ }
269
+ });
270
+ }
271
+
272
+ closeRecipientSelector() {
273
+ if (!this.recipientSelector.isOpen()) {
274
+ return;
275
+ }
276
+ this.recipientSelector.close();
277
+ this.scheduleRender();
278
+ }
279
+
280
+ applyRecipientSelection(address) {
281
+ if (!address) {
282
+ return;
283
+ }
284
+ this.inputValue = `@${address} `;
285
+ this.closeRecipientSelector();
286
+ this.scheduleRender();
287
+ }
288
+
289
+ ensureInputReady() {
290
+ if (!process.stdin.isTTY) {
291
+ return;
292
+ }
293
+ try {
294
+ process.stdin.setRawMode(true);
295
+ process.stdin.resume();
296
+ } catch (error) {
297
+ // Ignore
298
+ }
299
+ }
300
+
301
+ handleKeypress(str, key) {
302
+ // Check for Ctrl+C or ESC
303
+ if ((key && key.ctrl && key.name === 'c') || (key && key.name === 'escape' && !this.recipientSelector.isOpen())) {
304
+ this.cleanup();
305
+ process.exit(0);
306
+ }
307
+
308
+ if (this.inputDisabled) {
309
+ return;
310
+ }
311
+
312
+ if (this.recipientSelector.isOpen()) {
313
+ this.handleRecipientKeypress(key);
314
+ return;
315
+ }
316
+
317
+ if (key && key.ctrl && key.name === 'left') {
318
+ this.activatePrevTab();
319
+ return;
320
+ }
321
+ if (key && key.ctrl && key.name === 'right') {
322
+ this.activateNextTab();
323
+ return;
324
+ }
325
+ if (key && key.ctrl && key.name === 'w') {
326
+ this.closeActiveTab();
327
+ return;
328
+ }
329
+
330
+ if (key && key.name === 'up') {
331
+ this.scrollUp();
332
+ return;
333
+ }
334
+ if (key && key.name === 'down') {
335
+ this.scrollDown();
336
+ return;
337
+ }
338
+
339
+ if (key && key.name === 'return') {
340
+ this.submitInput();
341
+ return;
342
+ }
343
+
344
+ if (key && key.name === 'backspace') {
345
+ if (this.inputValue.length > 0) {
346
+ this.inputValue = this.inputValue.slice(0, -1);
347
+ this.scheduleRender();
348
+ }
349
+ return;
350
+ }
351
+
352
+ if (!key.ctrl && !key.meta && str) {
353
+ this.ensureInputReady();
354
+ this.inputValue += str;
355
+ if (this.inputValue === '@') {
356
+ this.openRecipientSelector();
357
+ return;
358
+ }
359
+ this.scheduleRender();
360
+ }
361
+ }
362
+
363
+ handleRecipientKeypress(key) {
364
+ const action = this.recipientSelector.handleKeypress(key);
365
+ if (action.action === 'close') {
366
+ this.closeRecipientSelector();
367
+ return;
368
+ }
369
+ if (action.action === 'select') {
370
+ this.applyRecipientSelection(action.address);
371
+ return;
372
+ }
373
+ if (action.action === 'update') {
374
+ this.scheduleRender();
375
+ }
376
+ }
377
+
378
+ submitInput() {
379
+ const trimmed = this.inputValue.trim();
380
+ if (!trimmed) {
381
+ return;
382
+ }
383
+
384
+ let outgoing = trimmed;
385
+ if (!trimmed.startsWith('@')) {
386
+ const peerAddress = this.getActivePeerAddress();
387
+ if (peerAddress) {
388
+ outgoing = `@${peerAddress} ${trimmed}`;
389
+ }
390
+ }
391
+
392
+ this.inputValue = '';
393
+ this.scrollOffset = 0;
394
+ this.scheduleRender();
395
+
396
+ if (this.sendCallback) {
397
+ this.sendCallback(outgoing);
398
+ }
399
+ }
400
+
401
+ scrollUp() {
402
+ this.scrollOffset += 1;
403
+ this.scheduleRender();
404
+ }
405
+
406
+ scrollDown() {
407
+ if (this.scrollOffset > 0) {
408
+ this.scrollOffset -= 1;
409
+ this.scheduleRender();
410
+ }
411
+ }
412
+
413
+ renderHeaderLines() {
414
+ return renderHeaderLines({
415
+ config: this.config,
416
+ myAddress: this.myAddress,
417
+ totalMessages: this.totalMessages,
418
+ messageExpiryHours: this.messageExpiryHours,
419
+ encryptionType: this.encryptionType,
420
+ lastConnectionStatus: this.lastConnectionStatus,
421
+ lastPollTime: this.lastPollTime
422
+ });
423
+ }
424
+
425
+ renderTabLines() {
426
+ return renderTabLines({
427
+ tabs: this.tabManager.getTabs(),
428
+ activeTabId: this.tabManager.getActiveTabId(),
429
+ applyStyle: this.applyStyle.bind(this)
430
+ });
431
+ }
432
+
433
+ formatMessageLine(msg) {
434
+ return formatMessageLine(msg, {
435
+ config: this.config,
436
+ myAddress: this.myAddress,
437
+ applyStyle: this.applyStyle.bind(this)
438
+ });
439
+ }
440
+
441
+ getFilteredMessages() {
442
+ const activePeer = this.getActivePeerAddress();
443
+ const activeTabId = this.tabManager.getActiveTabId();
444
+ return this.displayedMessages.filter((msg) => {
445
+ const type = normalizeMessageType(msg.messageType || msg.message_type);
446
+ if (activeTabId === MESSAGE_TYPES.GROUP) {
447
+ return type === MESSAGE_TYPES.GROUP;
448
+ }
449
+ return type === MESSAGE_TYPES.PRIVATE && msg.peerAddress === activePeer;
450
+ });
451
+ }
452
+
453
+ renderRecipientOverlay(availableHeight, width) {
454
+ return renderRecipientOverlay({
455
+ availableHeight,
456
+ width,
457
+ selector: this.recipientSelector
458
+ });
459
+ }
460
+
461
+ renderMessageLines(availableHeight, width) {
462
+ const lines = [];
463
+
464
+ if (this.blockingErrors.length > 0) {
465
+ lines.push('*** BLOCKED ***');
466
+ this.blockingErrors.forEach((err) => lines.push(err));
467
+ return lines.slice(0, availableHeight);
468
+ }
469
+
470
+ if (this.recipientSelector.isOpen()) {
471
+ return this.renderRecipientOverlay(availableHeight, width);
472
+ }
473
+
474
+ const filtered = this.getFilteredMessages();
475
+ const formatted = filtered.map((msg) => this.formatMessageLine(msg));
476
+ const total = formatted.length;
477
+ const visible = Math.max(availableHeight, 0);
478
+ const maxOffset = Math.max(total - visible, 0);
479
+ if (this.scrollOffset > maxOffset) {
480
+ this.scrollOffset = maxOffset;
481
+ }
482
+ const start = Math.max(total - visible - this.scrollOffset, 0);
483
+ const end = Math.min(start + visible, total);
484
+ return formatted.slice(start, end);
485
+ }
486
+
487
+ renderInputLine() {
488
+ return renderInputLine(this.inputValue);
489
+ }
490
+
491
+ renderStatusLine() {
492
+ return renderStatusLine(this.statusMessage, this.statusType, this.applyStyle.bind(this));
493
+ }
494
+
495
+ /**
496
+ * Schedule a render to happen on the next tick (non-blocking)
497
+ * Use this for non-critical updates like typing, scrolling
498
+ */
499
+ scheduleRender() {
500
+ if (this.renderScheduled) {
501
+ return;
502
+ }
503
+ this.renderScheduled = true;
504
+ setImmediate(() => {
505
+ this.renderScheduled = false;
506
+ this.renderNow();
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Force an immediate render (blocking)
512
+ * Use this sparingly for critical updates like new messages, errors
513
+ */
514
+ renderNow() {
515
+ this.render();
516
+ }
517
+
518
+ render() {
519
+ const rows = process.stdout.rows || 24;
520
+ const cols = process.stdout.columns || 80;
521
+ const innerWidth = Math.max(cols - 2, 10);
522
+ const headerLines = this.renderHeaderLines();
523
+ const tabRender = this.renderTabLines();
524
+ const tabLines = tabRender.lines;
525
+ const footerLines = 4; // input top + input + input bottom + status
526
+ const frameLines = 2; // top + bottom border
527
+ const dividerLines = 1;
528
+ const headerDividerLines = 1;
529
+ const messageHeight = Math.max(
530
+ rows - frameLines - headerLines.length - headerDividerLines - tabLines.length - dividerLines - footerLines,
531
+ 1
532
+ );
533
+
534
+ const messageLines = this.renderMessageLines(messageHeight, innerWidth);
535
+ const paddedMessages = [...messageLines];
536
+ while (paddedMessages.length < messageHeight) {
537
+ paddedMessages.push('');
538
+ }
539
+
540
+ const borderTop = `┌${'─'.repeat(innerWidth)}┐`;
541
+ const borderBottom = `└${'─'.repeat(innerWidth)}┘`;
542
+ const standardDivider = `├${'─'.repeat(innerWidth)}┤`;
543
+ const tabDividerInner = Array.from('─'.repeat(innerWidth));
544
+ if (tabRender.activeRange) {
545
+ const start = Math.max(0, Math.min(innerWidth - 1, tabRender.activeRange.start));
546
+ const end = Math.max(0, Math.min(innerWidth - 1, tabRender.activeRange.end));
547
+ for (let i = start; i <= end; i += 1) {
548
+ tabDividerInner[i] = ' ';
549
+ }
550
+ }
551
+ const tabDivider = `├${tabDividerInner.join('')}┤`;
552
+ const headerDivider = `├${'─'.repeat(innerWidth)}┤`;
553
+ const inputTop = standardDivider;
554
+ const inputBottom = standardDivider;
555
+
556
+ const outputLines = [
557
+ borderTop,
558
+ ...headerLines.map((line) => {
559
+ const padded = padLine(line, innerWidth);
560
+ return `│${this.applyStyle(padded, 'header')}│`;
561
+ }),
562
+ headerDivider,
563
+ ...tabLines.map((line) => `│${padLine(line, innerWidth)}│`),
564
+ tabDivider,
565
+ ...paddedMessages.map((line) => `│${padLine(line, innerWidth)}│`),
566
+ inputTop,
567
+ `│${padLine(this.renderInputLine(), innerWidth)}│`,
568
+ inputBottom,
569
+ `│${padLine(this.renderStatusLine(), innerWidth)}│`,
570
+ borderBottom
571
+ ];
572
+
573
+ const output = outputLines.join('\n');
574
+ process.stdout.write(`${ANSI.CLEAR}${ANSI.HOME}${output}`);
575
+
576
+ const inputRow = 1 + headerLines.length + headerDividerLines + tabLines.length + dividerLines + messageHeight + 2;
577
+ const cursorCol = Math.min(
578
+ 2 + 2 + this.inputValue.length,
579
+ (process.stdout.columns || 80) - 1
580
+ );
581
+ process.stdout.write(`\x1b[${inputRow};${cursorCol}H${ANSI.SHOW_CURSOR}`);
582
+ }
583
+
584
+ updateTopBar(status) {
585
+ this.lastConnectionStatus = status.connected;
586
+ if (status.lastPoll) {
587
+ this.lastPollTime = status.lastPoll;
588
+ }
589
+ this.scheduleRender();
590
+ }
591
+
592
+ updatePoolInfo(poolInfo) {
593
+ if (poolInfo) {
594
+ this.totalMessages = poolInfo.messages || 0;
595
+ this.messageExpiryHours = poolInfo.messageexpiryhours || 0;
596
+ this.encryptionType = poolInfo.cipher || PRIVACY.DEFAULT_ENCRYPTION;
597
+ this.scheduleRender();
598
+ }
599
+ }
600
+
601
+ addMessage(msg) {
602
+ const messageType = normalizeMessageType(msg.messageType || msg.message_type);
603
+ let peerAddress = msg.peerAddress || null;
604
+
605
+ if (messageType === MESSAGE_TYPES.PRIVATE && !peerAddress) {
606
+ if (msg.sender && msg.sender !== this.myAddress) {
607
+ peerAddress = msg.sender;
608
+ }
609
+ }
610
+
611
+ if (messageType === MESSAGE_TYPES.PRIVATE) {
612
+ const tab = this.tabManager.openPrivateTab(peerAddress, false, msg.timestamp);
613
+ if (tab && this.tabManager.getActiveTabId() !== tab.id) {
614
+ this.tabManager.markUnread(tab.id);
615
+ }
616
+ }
617
+
618
+ if (messageType === MESSAGE_TYPES.GROUP) {
619
+ this.tabManager.markGroupUnread();
620
+ }
621
+
622
+ this.displayedMessages.push({
623
+ ...msg,
624
+ messageType,
625
+ peerAddress
626
+ });
627
+
628
+ this.displayedMessages.sort((a, b) => a.timestamp - b.timestamp);
629
+ this.renderNow();
630
+ }
631
+
632
+ addSystemMessage(type, message) {
633
+ const hash = `sys-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
634
+ this.addMessage({
635
+ sender: 'SYSTEM',
636
+ message: `[${type.toUpperCase()}] ${message}`,
637
+ timestamp: Math.floor(Date.now() / 1000),
638
+ hash: hash,
639
+ signature: '',
640
+ messageType: MESSAGE_TYPES.GROUP,
641
+ isSystem: true,
642
+ systemType: type
643
+ });
644
+ return hash;
645
+ }
646
+
647
+ removeMessage(hash) {
648
+ this.displayedMessages = this.displayedMessages.filter(m => m.hash !== hash);
649
+ this.scheduleRender();
650
+ }
651
+
652
+ showError(errorMsg) {
653
+ return this.addSystemMessage('error', errorMsg);
654
+ }
655
+
656
+ showInfo(infoMsg) {
657
+ return this.addSystemMessage('info', infoMsg);
658
+ }
659
+
660
+ showSuccess(successMsg) {
661
+ return this.addSystemMessage('success', successMsg);
662
+ }
663
+
664
+ updateSendStatus(message, type = 'info') {
665
+ this.statusMessage = message || '';
666
+ this.statusType = type;
667
+ this.scheduleRender();
668
+ }
669
+
670
+ clearSendStatus() {
671
+ this.statusMessage = '';
672
+ this.scheduleRender();
673
+ }
674
+
675
+ showBlockingErrors(errors) {
676
+ this.blockingErrors = errors || [];
677
+ this.inputDisabled = true;
678
+ this.renderNow();
679
+ }
680
+
681
+ clearBlockingErrors() {
682
+ this.blockingErrors = [];
683
+ this.inputDisabled = false;
684
+ this.renderNow();
685
+ }
686
+
687
+ onSend(callback) {
688
+ this.sendCallback = callback;
689
+ }
690
+ }