@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,114 @@
1
+ /**
2
+ * Recipient selector state for Charsm UI
3
+ * Manages loading, selection index, and scroll position.
4
+ * @module RecipientSelector
5
+ */
6
+
7
+ export class RecipientSelector {
8
+ constructor() {
9
+ this.open = false;
10
+ this.loading = false;
11
+ this.items = [];
12
+ this.index = 0;
13
+ this.scroll = 0;
14
+ this.loadPromise = null;
15
+ }
16
+
17
+ isOpen() {
18
+ return this.open;
19
+ }
20
+
21
+ async openSelector({ cachedItems, loadItems, onUpdate, onError }) {
22
+ if (this.open || typeof loadItems !== 'function') {
23
+ return;
24
+ }
25
+
26
+ this.open = true;
27
+ this.loading = false;
28
+
29
+ if (cachedItems && cachedItems.length > 0) {
30
+ this.items = cachedItems;
31
+ this.index = 0;
32
+ this.scroll = 0;
33
+ if (onUpdate) {
34
+ onUpdate();
35
+ }
36
+ } else {
37
+ this.loading = true;
38
+ if (onUpdate) {
39
+ onUpdate();
40
+ }
41
+ }
42
+
43
+ try {
44
+ const recipients = await this.load(loadItems);
45
+ this.items = recipients;
46
+ this.index = 0;
47
+ this.scroll = 0;
48
+ this.loading = false;
49
+ if (onUpdate) {
50
+ onUpdate();
51
+ }
52
+ } catch (error) {
53
+ this.open = false;
54
+ this.loading = false;
55
+ if (onError) {
56
+ onError(error);
57
+ }
58
+ }
59
+ }
60
+
61
+ close() {
62
+ this.open = false;
63
+ this.loading = false;
64
+ }
65
+
66
+ handleKeypress(key) {
67
+ if (!key) {
68
+ return { action: 'noop' };
69
+ }
70
+
71
+ if (key.name === 'escape') {
72
+ return { action: 'close' };
73
+ }
74
+
75
+ if (key.name === 'up') {
76
+ if (this.index > 0) {
77
+ this.index -= 1;
78
+ if (this.index < this.scroll) {
79
+ this.scroll = this.index;
80
+ }
81
+ return { action: 'update' };
82
+ }
83
+ return { action: 'noop' };
84
+ }
85
+
86
+ if (key.name === 'down') {
87
+ if (this.index < this.items.length - 1) {
88
+ this.index += 1;
89
+ return { action: 'update' };
90
+ }
91
+ return { action: 'noop' };
92
+ }
93
+
94
+ if (key.name === 'return') {
95
+ return { action: 'select', address: this.items[this.index] };
96
+ }
97
+
98
+ return { action: 'noop' };
99
+ }
100
+
101
+ async load(loadItems) {
102
+ if (this.loadPromise) {
103
+ return this.loadPromise;
104
+ }
105
+
106
+ this.loadPromise = Promise.resolve(loadItems());
107
+
108
+ try {
109
+ return await this.loadPromise;
110
+ } finally {
111
+ this.loadPromise = null;
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tab manager for Charsm UI
3
+ * Tracks group and private tabs, unread state, and ordering.
4
+ * @module TabManager
5
+ */
6
+
7
+ export class TabManager {
8
+ constructor() {
9
+ this.tabs = [];
10
+ this.activeTabId = 'group';
11
+ this.privateTabFirstSeen = new Map();
12
+ this.closedPrivateTabs = new Set();
13
+ }
14
+
15
+ initialize() {
16
+ this.tabs = [{
17
+ id: 'group',
18
+ label: 'Group',
19
+ type: 'group',
20
+ unread: false,
21
+ firstSeen: 0
22
+ }];
23
+ this.activeTabId = 'group';
24
+ }
25
+
26
+ getTabs() {
27
+ return this.tabs;
28
+ }
29
+
30
+ getActiveTabId() {
31
+ return this.activeTabId;
32
+ }
33
+
34
+ getActivePeerAddress() {
35
+ if (!this.activeTabId.startsWith('dm:')) {
36
+ return null;
37
+ }
38
+ return this.activeTabId.slice(3);
39
+ }
40
+
41
+ setActiveTab(tabId) {
42
+ const tab = this.tabs.find((entry) => entry.id === tabId);
43
+ if (!tab) {
44
+ return false;
45
+ }
46
+ this.activeTabId = tabId;
47
+ tab.unread = false;
48
+ return true;
49
+ }
50
+
51
+ activateNextTab() {
52
+ if (this.tabs.length === 0) {
53
+ return false;
54
+ }
55
+ const currentIndex = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
56
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % this.tabs.length;
57
+ return this.setActiveTab(this.tabs[nextIndex].id);
58
+ }
59
+
60
+ activatePrevTab() {
61
+ if (this.tabs.length === 0) {
62
+ return false;
63
+ }
64
+ const currentIndex = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
65
+ const prevIndex = currentIndex === -1
66
+ ? 0
67
+ : (currentIndex - 1 + this.tabs.length) % this.tabs.length;
68
+ return this.setActiveTab(this.tabs[prevIndex].id);
69
+ }
70
+
71
+ closeActiveTab() {
72
+ if (this.activeTabId === 'group') {
73
+ return false;
74
+ }
75
+
76
+ const peerAddress = this.getActivePeerAddress();
77
+ this.tabs = this.tabs.filter((tab) => tab.id !== this.activeTabId);
78
+ if (peerAddress) {
79
+ this.closedPrivateTabs.add(peerAddress);
80
+ }
81
+ this.activeTabId = 'group';
82
+ this.sortTabs();
83
+ return true;
84
+ }
85
+
86
+ openPrivateTab(address, activate = false, timestamp = null) {
87
+ if (!address) {
88
+ return null;
89
+ }
90
+
91
+ const tabId = `dm:${address}`;
92
+ let tab = this.tabs.find((entry) => entry.id === tabId);
93
+
94
+ if (!this.privateTabFirstSeen.has(address)) {
95
+ const firstSeen = timestamp || Math.floor(Date.now() / 1000);
96
+ this.privateTabFirstSeen.set(address, firstSeen);
97
+ }
98
+
99
+ if (!tab) {
100
+ if (this.closedPrivateTabs.has(address)) {
101
+ this.closedPrivateTabs.delete(address);
102
+ }
103
+
104
+ tab = {
105
+ id: tabId,
106
+ label: this.formatTabLabel(address),
107
+ type: 'dm',
108
+ address: address,
109
+ unread: false,
110
+ firstSeen: this.privateTabFirstSeen.get(address)
111
+ };
112
+
113
+ this.tabs.push(tab);
114
+ this.sortTabs();
115
+ }
116
+
117
+ if (activate) {
118
+ this.setActiveTab(tabId);
119
+ }
120
+
121
+ return tab;
122
+ }
123
+
124
+ markUnread(tabId) {
125
+ const tab = this.tabs.find((entry) => entry.id === tabId);
126
+ if (tab) {
127
+ tab.unread = true;
128
+ }
129
+ }
130
+
131
+ markGroupUnread() {
132
+ if (this.activeTabId === 'group') {
133
+ return;
134
+ }
135
+ const groupTab = this.tabs.find((tab) => tab.id === 'group');
136
+ if (groupTab) {
137
+ groupTab.unread = true;
138
+ }
139
+ }
140
+
141
+ formatTabLabel(address) {
142
+ if (!address) {
143
+ return 'Unknown';
144
+ }
145
+
146
+ const trimmed = address.trim();
147
+ if (trimmed.length <= 6) {
148
+ return trimmed;
149
+ }
150
+
151
+ return `${trimmed.slice(0, 3)}...${trimmed.slice(-3)}`;
152
+ }
153
+
154
+ sortTabs() {
155
+ const groupTab = this.tabs.find((tab) => tab.type === 'group');
156
+ const dmTabs = this.tabs
157
+ .filter((tab) => tab.type === 'dm')
158
+ .sort((a, b) => a.firstSeen - b.firstSeen);
159
+
160
+ this.tabs = groupTab ? [groupTab, ...dmTabs] : dmTabs;
161
+ }
162
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Rendering helpers for Charsm UI
3
+ * @module ui/render
4
+ */
5
+
6
+ import { ADDRESS, TIME } from '../constants.js';
7
+ import { formatTimestamp, parseRpcHost } from '../utils.js';
8
+
9
+ export const stripAnsi = (value) => {
10
+ if (!value) {
11
+ return '';
12
+ }
13
+ return value.replace(/\x1b\[[0-9;]*m/g, '');
14
+ };
15
+
16
+ export const padLine = (value, width) => {
17
+ const raw = value || '';
18
+ const len = stripAnsi(raw).length;
19
+ if (len >= width) {
20
+ return raw;
21
+ }
22
+ return raw + ' '.repeat(width - len);
23
+ };
24
+
25
+ export const renderHeaderLines = ({
26
+ config,
27
+ myAddress,
28
+ totalMessages,
29
+ messageExpiryHours,
30
+ encryptionType,
31
+ lastConnectionStatus,
32
+ lastPollTime
33
+ }) => {
34
+ const rpcUrl = parseRpcHost(config.rpc_url);
35
+ const connectedIndicator = lastConnectionStatus ? '●' : '○';
36
+ const lastPollStr = lastPollTime
37
+ ? formatTimestamp(lastPollTime, config.timezone)
38
+ : TIME.PLACEHOLDER;
39
+
40
+ let timezoneDisplay = config.timezone || 'UTC';
41
+ if (timezoneDisplay !== 'UTC') {
42
+ if (!timezoneDisplay.startsWith('+') && !timezoneDisplay.startsWith('-')) {
43
+ timezoneDisplay = `+${timezoneDisplay}`;
44
+ }
45
+ timezoneDisplay = `UTC${timezoneDisplay}`;
46
+ }
47
+
48
+ const line1 = `Neurai DePIN | ${connectedIndicator} RPC: ${rpcUrl} | Token: ${config.token} | Time: ${timezoneDisplay}`;
49
+ const line2 = `Address: ${myAddress} | Total: ${totalMessages} | Expiry: ${messageExpiryHours}h | Encryption: ${encryptionType} | Last check: ${lastPollStr}`;
50
+
51
+ return [line1, line2];
52
+ };
53
+
54
+ export const renderTabLines = ({ tabs, activeTabId, applyStyle }) => {
55
+ const blocks = tabs.map((tab) => {
56
+ const label = tab.unread ? `${tab.label}*` : tab.label;
57
+ const content = ` ${label} `;
58
+ const borderChar = tab.id === activeTabId ? '═' : '─';
59
+ const isActive = tab.id === activeTabId;
60
+ const top = `┌${borderChar.repeat(content.length)}┐`;
61
+ const middle = `│${content}│`;
62
+ return {
63
+ lines: [top, middle],
64
+ styleId: isActive ? 'tabActive' : 'tabInactive',
65
+ isActive
66
+ };
67
+ });
68
+
69
+ if (blocks.length === 0) {
70
+ return { lines: [''], activeRange: null };
71
+ }
72
+
73
+ const height = 2;
74
+ const combined = [];
75
+ let activeRange = null;
76
+ let cursor = 0;
77
+ blocks.forEach((block, index) => {
78
+ const blockWidth = block.lines[0].length;
79
+ if (block.isActive) {
80
+ activeRange = { start: cursor, end: cursor + blockWidth - 1 };
81
+ }
82
+ cursor += blockWidth;
83
+ if (index < blocks.length - 1) {
84
+ cursor += 1;
85
+ }
86
+ });
87
+ for (let i = 0; i < height; i += 1) {
88
+ const line = blocks.map((block) => {
89
+ return applyStyle(block.lines[i], block.styleId);
90
+ }).join(' ');
91
+ combined.push(line);
92
+ }
93
+
94
+ return { lines: combined, activeRange };
95
+ };
96
+
97
+ export const formatMessageLine = (msg, { config, myAddress, applyStyle }) => {
98
+ const time = formatTimestamp(msg.timestamp, config.timezone);
99
+ const isMe = msg.sender === myAddress;
100
+ const senderLabel = isMe ? 'YOU' : msg.sender.slice(0, ADDRESS.TRUNCATE_LENGTH);
101
+ const line = `[${time}] ${senderLabel}: ${msg.message}`;
102
+
103
+ if (msg.isSystem) {
104
+ const styleId = msg.systemType === 'error'
105
+ ? 'msgError'
106
+ : msg.systemType === 'success'
107
+ ? 'msgSuccess'
108
+ : 'msgInfo';
109
+ return applyStyle(line, styleId);
110
+ }
111
+
112
+ const styleId = isMe ? 'msgMe' : 'msgOther';
113
+ return applyStyle(line, styleId);
114
+ };
115
+
116
+ export const renderRecipientOverlay = ({ availableHeight, width, selector }) => {
117
+ const contentLines = [];
118
+
119
+ if (selector.loading) {
120
+ contentLines.push('(loading recipients...)');
121
+ } else if (!selector.items.length) {
122
+ contentLines.push('(no recipients available)');
123
+ } else {
124
+ const maxVisible = Math.max(1, availableHeight - 2);
125
+ if (selector.index >= selector.scroll + maxVisible) {
126
+ selector.scroll = selector.index - maxVisible + 1;
127
+ }
128
+
129
+ const slice = selector.items.slice(selector.scroll, selector.scroll + maxVisible);
130
+ slice.forEach((address, idx) => {
131
+ const absoluteIndex = selector.scroll + idx;
132
+ const prefix = absoluteIndex === selector.index ? '>' : ' ';
133
+ contentLines.push(`${prefix} ${address}`);
134
+ });
135
+ }
136
+
137
+ const frameWidth = Math.min(Math.max(width || 40, 40), 70);
138
+ const innerWidth = frameWidth - 2;
139
+ const top = `┌${'─'.repeat(innerWidth)}┐`;
140
+ const bottom = `└${'─'.repeat(innerWidth)}┘`;
141
+ const framed = [top, ...contentLines.map((line) => `│${padLine(line, innerWidth)}│`), bottom];
142
+
143
+ const frameHeight = framed.length;
144
+ const leftPadding = Math.max(Math.floor((width - frameWidth) / 2), 0);
145
+ const topPadding = Math.max(Math.floor((availableHeight - frameHeight) / 2), 0);
146
+ const paddedFrame = framed.map((line) => `${' '.repeat(leftPadding)}${line}`);
147
+
148
+ const output = [];
149
+ for (let i = 0; i < topPadding; i += 1) {
150
+ output.push('');
151
+ }
152
+ output.push(...paddedFrame);
153
+
154
+ while (output.length < availableHeight) {
155
+ output.push('');
156
+ }
157
+
158
+ return output.slice(0, availableHeight);
159
+ };
160
+
161
+ export const renderInputLine = (inputValue) => `> ${inputValue}`;
162
+
163
+ export const renderStatusLine = (statusMessage, statusType, applyStyle) => {
164
+ if (!statusMessage) {
165
+ return '';
166
+ }
167
+ const styleId = statusType === 'error'
168
+ ? 'statusError'
169
+ : statusType === 'success'
170
+ ? 'statusSuccess'
171
+ : 'statusInfo';
172
+ return applyStyle(statusMessage, styleId);
173
+ };