@darksol/terminal 0.4.7 → 0.4.9
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/package.json +1 -1
- package/src/web/commands.js +287 -31
- package/src/web/server.js +43 -0
- package/src/web/terminal.js +184 -43
package/package.json
CHANGED
package/src/web/commands.js
CHANGED
|
@@ -69,6 +69,169 @@ const USDC_ADDRESSES = {
|
|
|
69
69
|
|
|
70
70
|
const ERC20_ABI = ['function balanceOf(address) view returns (uint256)'];
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Handle interactive menu selections from the client
|
|
74
|
+
*/
|
|
75
|
+
export async function handleMenuSelect(id, value, item, ws) {
|
|
76
|
+
switch (id) {
|
|
77
|
+
case 'wallet_select':
|
|
78
|
+
return await showWalletDetail(value, ws);
|
|
79
|
+
|
|
80
|
+
case 'wallet_action':
|
|
81
|
+
switch (value) {
|
|
82
|
+
case 'receive': {
|
|
83
|
+
const name = getConfig('activeWallet');
|
|
84
|
+
if (!name) return {};
|
|
85
|
+
const { loadWallet } = await import('../wallet/keystore.js');
|
|
86
|
+
const w = loadWallet(name);
|
|
87
|
+
ws.sendLine('');
|
|
88
|
+
ws.sendLine(`${ANSI.gold} ◆ RECEIVE — ${name}${ANSI.reset}`);
|
|
89
|
+
ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
|
|
90
|
+
ws.sendLine('');
|
|
91
|
+
ws.sendLine(` ${ANSI.white}Your address:${ANSI.reset}`);
|
|
92
|
+
ws.sendLine('');
|
|
93
|
+
const addr = w.address;
|
|
94
|
+
ws.sendLine(` ${ANSI.dim}┌${'─'.repeat(addr.length + 4)}┐${ANSI.reset}`);
|
|
95
|
+
ws.sendLine(` ${ANSI.dim}│ ${ANSI.gold}${addr}${ANSI.dim} │${ANSI.reset}`);
|
|
96
|
+
ws.sendLine(` ${ANSI.dim}└${'─'.repeat(addr.length + 4)}┘${ANSI.reset}`);
|
|
97
|
+
ws.sendLine('');
|
|
98
|
+
ws.sendLine(` ${ANSI.dim}Works on ALL EVM chains: Base • Ethereum • Arbitrum • Optimism • Polygon${ANSI.reset}`);
|
|
99
|
+
ws.sendLine(` ${ANSI.red}Make sure the sender is on the same chain!${ANSI.reset}`);
|
|
100
|
+
ws.sendLine('');
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
case 'send':
|
|
104
|
+
ws.sendLine('');
|
|
105
|
+
ws.sendLine(` ${ANSI.gold}◆ SEND${ANSI.reset}`);
|
|
106
|
+
ws.sendLine(` ${ANSI.dim}Sending requires wallet password — use the CLI:${ANSI.reset}`);
|
|
107
|
+
ws.sendLine(` ${ANSI.gold}darksol send --to 0x... --amount 0.1 --token ETH${ANSI.reset}`);
|
|
108
|
+
ws.sendLine(` ${ANSI.gold}darksol send${ANSI.reset} ${ANSI.dim}(interactive mode)${ANSI.reset}`);
|
|
109
|
+
ws.sendLine('');
|
|
110
|
+
return {};
|
|
111
|
+
case 'portfolio':
|
|
112
|
+
return await handleCommand('portfolio', ws);
|
|
113
|
+
case 'history':
|
|
114
|
+
return await handleCommand('history', ws);
|
|
115
|
+
case 'switch': {
|
|
116
|
+
const chains = ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'];
|
|
117
|
+
const current = getConfig('chain') || 'base';
|
|
118
|
+
ws.sendMenu('chain_select', '◆ Select Chain', chains.map(c => ({
|
|
119
|
+
value: c,
|
|
120
|
+
label: c === current ? `★ ${c}` : c,
|
|
121
|
+
desc: c === current ? 'current' : '',
|
|
122
|
+
})));
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
case 'back':
|
|
126
|
+
ws.sendLine('');
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case 'chain_select':
|
|
132
|
+
setConfig('chain', value);
|
|
133
|
+
ws.sendLine('');
|
|
134
|
+
ws.sendLine(` ${ANSI.green}✓ Chain set to ${value}${ANSI.reset}`);
|
|
135
|
+
ws.sendLine('');
|
|
136
|
+
return {};
|
|
137
|
+
|
|
138
|
+
case 'keys_provider':
|
|
139
|
+
if (value === 'back') {
|
|
140
|
+
ws.sendLine('');
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
// Ask for the key via a prompt
|
|
144
|
+
const svc = SERVICES[value];
|
|
145
|
+
if (!svc) return {};
|
|
146
|
+
ws.sendLine('');
|
|
147
|
+
ws.sendLine(` ${ANSI.gold}◆ ${svc.name}${ANSI.reset}`);
|
|
148
|
+
ws.sendLine(` ${ANSI.dim}Docs: ${svc.docsUrl}${ANSI.reset}`);
|
|
149
|
+
if (value === 'ollama') {
|
|
150
|
+
ws.sendLine(` ${ANSI.dim}Enter your Ollama host URL (e.g. http://localhost:11434)${ANSI.reset}`);
|
|
151
|
+
} else {
|
|
152
|
+
ws.sendLine(` ${ANSI.dim}Paste your API key below:${ANSI.reset}`);
|
|
153
|
+
}
|
|
154
|
+
ws.sendLine('');
|
|
155
|
+
// Send a prompt request to the client
|
|
156
|
+
ws.send(JSON.stringify({
|
|
157
|
+
type: 'prompt',
|
|
158
|
+
id: 'keys_input',
|
|
159
|
+
label: `${svc.name} key:`,
|
|
160
|
+
service: value,
|
|
161
|
+
mask: value !== 'ollama', // mask API keys, not URLs
|
|
162
|
+
}));
|
|
163
|
+
return {};
|
|
164
|
+
|
|
165
|
+
case 'config_action':
|
|
166
|
+
if (value === 'chain') {
|
|
167
|
+
const chains = ['base', 'ethereum', 'arbitrum', 'optimism', 'polygon'];
|
|
168
|
+
const current = getConfig('chain') || 'base';
|
|
169
|
+
ws.sendMenu('chain_select', '◆ Select Chain', chains.map(c => ({
|
|
170
|
+
value: c,
|
|
171
|
+
label: c === current ? `★ ${c}` : c,
|
|
172
|
+
desc: c === current ? 'current' : '',
|
|
173
|
+
})));
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
if (value === 'keys') {
|
|
177
|
+
return await handleCommand('keys', ws);
|
|
178
|
+
}
|
|
179
|
+
ws.sendLine('');
|
|
180
|
+
return {};
|
|
181
|
+
|
|
182
|
+
case 'main_menu':
|
|
183
|
+
return await handleCommand(value, ws);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handle text prompt responses from the client
|
|
191
|
+
*/
|
|
192
|
+
export async function handlePromptResponse(id, value, meta, ws) {
|
|
193
|
+
if (id === 'keys_input') {
|
|
194
|
+
const service = meta.service;
|
|
195
|
+
const svc = SERVICES[service];
|
|
196
|
+
if (!svc || !value) {
|
|
197
|
+
ws.sendLine(` ${ANSI.red}✗ Cancelled${ANSI.reset}`);
|
|
198
|
+
ws.sendLine('');
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate
|
|
203
|
+
if (svc.validate && !svc.validate(value)) {
|
|
204
|
+
ws.sendLine(` ${ANSI.red}✗ Invalid format for ${svc.name}${ANSI.reset}`);
|
|
205
|
+
if (service === 'openai') ws.sendLine(` ${ANSI.dim}Key should start with sk-${ANSI.reset}`);
|
|
206
|
+
if (service === 'anthropic') ws.sendLine(` ${ANSI.dim}Key should start with sk-ant-${ANSI.reset}`);
|
|
207
|
+
if (service === 'openrouter') ws.sendLine(` ${ANSI.dim}Key should start with sk-or-${ANSI.reset}`);
|
|
208
|
+
if (service === 'ollama') ws.sendLine(` ${ANSI.dim}Should be a URL like http://localhost:11434${ANSI.reset}`);
|
|
209
|
+
ws.sendLine('');
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Store it
|
|
214
|
+
try {
|
|
215
|
+
addKeyDirect(service, value);
|
|
216
|
+
ws.sendLine(` ${ANSI.green}✓ ${svc.name} key stored securely${ANSI.reset}`);
|
|
217
|
+
ws.sendLine(` ${ANSI.dim}Encrypted at ~/.darksol/keys/vault.json${ANSI.reset}`);
|
|
218
|
+
ws.sendLine('');
|
|
219
|
+
|
|
220
|
+
// Clear cached AI engine
|
|
221
|
+
// (chatEngines is WeakMap keyed by ws, but we can't access the real ws here —
|
|
222
|
+
// the engine will reinit on next ai command since keys changed)
|
|
223
|
+
ws.sendLine(` ${ANSI.green}● AI ready!${ANSI.reset} ${ANSI.dim}Type ${ANSI.gold}ai <question>${ANSI.dim} to start chatting.${ANSI.reset}`);
|
|
224
|
+
ws.sendLine('');
|
|
225
|
+
} catch (err) {
|
|
226
|
+
ws.sendLine(` ${ANSI.red}✗ Failed: ${err.message}${ANSI.reset}`);
|
|
227
|
+
ws.sendLine('');
|
|
228
|
+
}
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
|
|
72
235
|
/**
|
|
73
236
|
* AI status check — shown on connection
|
|
74
237
|
*/
|
|
@@ -90,14 +253,12 @@ export function getAIStatus() {
|
|
|
90
253
|
return [
|
|
91
254
|
` ${red}○ AI not configured${reset} ${dim}— no LLM provider connected${reset}`,
|
|
92
255
|
'',
|
|
93
|
-
` ${gold}
|
|
256
|
+
` ${dim}Type ${gold}keys${dim} to set up an LLM provider, or paste directly:${reset}`,
|
|
94
257
|
` ${green}keys add openai sk-...${reset} ${dim}OpenAI (GPT-4o)${reset}`,
|
|
95
258
|
` ${green}keys add anthropic sk-ant-...${reset} ${dim}Anthropic (Claude)${reset}`,
|
|
96
259
|
` ${green}keys add openrouter sk-or-...${reset} ${dim}OpenRouter (any model)${reset}`,
|
|
97
260
|
` ${green}keys add ollama http://...${reset} ${dim}Ollama (free, local)${reset}`,
|
|
98
261
|
'',
|
|
99
|
-
` ${dim}Or run the full setup wizard: ${gold}darksol setup${reset}`,
|
|
100
|
-
'',
|
|
101
262
|
].join('\r\n');
|
|
102
263
|
}
|
|
103
264
|
|
|
@@ -402,47 +563,121 @@ async function cmdMarket(args, ws) {
|
|
|
402
563
|
// WALLET
|
|
403
564
|
// ══════════════════════════════════════════════════
|
|
404
565
|
async function cmdWallet(args, ws) {
|
|
405
|
-
const sub = args[0]
|
|
566
|
+
const sub = args[0];
|
|
406
567
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
568
|
+
// If a specific subcommand, handle directly
|
|
569
|
+
if (sub === 'balance') {
|
|
570
|
+
return await showWalletDetail(args[1] || getConfig('activeWallet'), ws);
|
|
571
|
+
}
|
|
411
572
|
|
|
412
|
-
|
|
413
|
-
|
|
573
|
+
if (sub === 'use' && args[1]) {
|
|
574
|
+
setConfig('activeWallet', args[1]);
|
|
575
|
+
ws.sendLine(` ${ANSI.green}✓ Active wallet set to "${args[1]}"${ANSI.reset}`);
|
|
576
|
+
ws.sendLine('');
|
|
577
|
+
return {};
|
|
578
|
+
}
|
|
414
579
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const indicator = w === active ? `${ANSI.gold}► ${ANSI.reset}` : ' ';
|
|
420
|
-
ws.sendLine(` ${indicator}${ANSI.white}${w}${ANSI.reset}`);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
580
|
+
// Default: interactive wallet picker
|
|
581
|
+
const { listWallets } = await import('../wallet/keystore.js');
|
|
582
|
+
const wallets = listWallets();
|
|
583
|
+
const active = getConfig('activeWallet');
|
|
423
584
|
|
|
585
|
+
if (wallets.length === 0) {
|
|
586
|
+
ws.sendLine(`${ANSI.gold} ◆ WALLETS${ANSI.reset}`);
|
|
587
|
+
ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
|
|
588
|
+
ws.sendLine(` ${ANSI.dim}No wallets found.${ANSI.reset}`);
|
|
589
|
+
ws.sendLine(` ${ANSI.dim}Create one: ${ANSI.gold}darksol wallet create <name>${ANSI.reset}`);
|
|
424
590
|
ws.sendLine('');
|
|
425
591
|
return {};
|
|
426
592
|
}
|
|
427
593
|
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
594
|
+
if (wallets.length === 1) {
|
|
595
|
+
// Only one wallet — go straight to it
|
|
596
|
+
return await showWalletDetail(wallets[0].name, ws);
|
|
597
|
+
}
|
|
431
598
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
599
|
+
// Multiple wallets — show interactive menu
|
|
600
|
+
const menuItems = wallets.map(w => ({
|
|
601
|
+
value: w.name,
|
|
602
|
+
label: `${w.name === active ? '★ ' : ''}${w.name}`,
|
|
603
|
+
desc: `${w.address.slice(0, 6)}...${w.address.slice(-4)}`,
|
|
604
|
+
}));
|
|
605
|
+
|
|
606
|
+
ws.sendMenu('wallet_select', '◆ Select Wallet', menuItems);
|
|
607
|
+
return {};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function showWalletDetail(name, ws) {
|
|
611
|
+
if (!name) {
|
|
612
|
+
ws.sendLine(` ${ANSI.red}No wallet selected${ANSI.reset}`);
|
|
613
|
+
ws.sendLine('');
|
|
614
|
+
return {};
|
|
615
|
+
}
|
|
437
616
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
617
|
+
const { loadWallet } = await import('../wallet/keystore.js');
|
|
618
|
+
let walletData;
|
|
619
|
+
try {
|
|
620
|
+
walletData = loadWallet(name);
|
|
621
|
+
} catch {
|
|
622
|
+
ws.sendLine(` ${ANSI.red}Wallet "${name}" not found${ANSI.reset}`);
|
|
441
623
|
ws.sendLine('');
|
|
442
624
|
return {};
|
|
443
625
|
}
|
|
444
626
|
|
|
445
|
-
|
|
627
|
+
const chain = getConfig('chain') || 'base';
|
|
628
|
+
const provider = new ethers.JsonRpcProvider(RPCS[chain]);
|
|
629
|
+
|
|
630
|
+
ws.sendLine(`${ANSI.gold} ◆ WALLET — ${name}${ANSI.reset}`);
|
|
631
|
+
ws.sendLine(`${ANSI.dim} ${'─'.repeat(50)}${ANSI.reset}`);
|
|
632
|
+
ws.sendLine(` ${ANSI.darkGold}Address${ANSI.reset} ${ANSI.white}${walletData.address}${ANSI.reset}`);
|
|
633
|
+
ws.sendLine(` ${ANSI.darkGold}Chain${ANSI.reset} ${ANSI.white}${chain}${ANSI.reset}`);
|
|
634
|
+
ws.sendLine(` ${ANSI.darkGold}Created${ANSI.reset} ${ANSI.dim}${walletData.createdAt ? new Date(walletData.createdAt).toLocaleDateString() : 'unknown'}${ANSI.reset}`);
|
|
635
|
+
ws.sendLine(` ${ANSI.darkGold}Encryption${ANSI.reset} ${ANSI.dim}AES-256-GCM + scrypt${ANSI.reset}`);
|
|
636
|
+
|
|
637
|
+
// Fetch balance
|
|
638
|
+
ws.sendLine('');
|
|
639
|
+
ws.sendLine(` ${ANSI.dim}Fetching balance...${ANSI.reset}`);
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
const balance = await provider.getBalance(walletData.address);
|
|
643
|
+
const ethBal = parseFloat(ethers.formatEther(balance));
|
|
644
|
+
|
|
645
|
+
// Also check USDC
|
|
646
|
+
const usdcAddr = USDC_ADDRESSES[chain];
|
|
647
|
+
let usdcBal = 0;
|
|
648
|
+
if (usdcAddr) {
|
|
649
|
+
try {
|
|
650
|
+
const usdc = new ethers.Contract(usdcAddr, ['function balanceOf(address) view returns (uint256)'], provider);
|
|
651
|
+
const raw = await usdc.balanceOf(walletData.address);
|
|
652
|
+
usdcBal = parseFloat(ethers.formatUnits(raw, 6));
|
|
653
|
+
} catch {}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Get ETH price for USD value
|
|
657
|
+
const ethPrice = await getEthPrice();
|
|
658
|
+
const usdValue = (ethBal * ethPrice) + usdcBal;
|
|
659
|
+
|
|
660
|
+
// Overwrite "Fetching..." line
|
|
661
|
+
ws.sendLine(`\x1b[1A\x1b[2K ${ANSI.darkGold}ETH${ANSI.reset} ${ANSI.white}${ethBal.toFixed(6)}${ANSI.reset} ${ANSI.dim}($${(ethBal * ethPrice).toFixed(2)})${ANSI.reset}`);
|
|
662
|
+
ws.sendLine(` ${ANSI.darkGold}USDC${ANSI.reset} ${ANSI.white}$${usdcBal.toFixed(2)}${ANSI.reset}`);
|
|
663
|
+
ws.sendLine(` ${ANSI.darkGold}Total${ANSI.reset} ${ANSI.green}$${usdValue.toFixed(2)}${ANSI.reset}`);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
ws.sendLine(` ${ANSI.red}Balance fetch failed: ${err.message}${ANSI.reset}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
ws.sendLine('');
|
|
669
|
+
|
|
670
|
+
// Show action menu
|
|
671
|
+
ws.sendMenu('wallet_action', '◆ What would you like to do?', [
|
|
672
|
+
{ value: 'receive', label: '📥 Receive', desc: 'Show address to receive funds' },
|
|
673
|
+
{ value: 'send', label: '📤 Send', desc: 'Send ETH or tokens (CLI required)' },
|
|
674
|
+
{ value: 'portfolio', label: '📊 Portfolio', desc: 'Multi-chain balance view' },
|
|
675
|
+
{ value: 'history', label: '📜 History', desc: 'Transaction history' },
|
|
676
|
+
{ value: 'switch', label: '🔄 Switch chain', desc: `Currently: ${chain}` },
|
|
677
|
+
{ value: 'back', label: '← Back', desc: '' },
|
|
678
|
+
]);
|
|
679
|
+
|
|
680
|
+
return {};
|
|
446
681
|
}
|
|
447
682
|
|
|
448
683
|
// ══════════════════════════════════════════════════
|
|
@@ -581,8 +816,16 @@ async function cmdConfig(ws) {
|
|
|
581
816
|
ws.sendLine(` ${ANSI.darkGold}Wallet${ANSI.reset} ${ANSI.white}${wallet}${ANSI.reset}`);
|
|
582
817
|
ws.sendLine(` ${ANSI.darkGold}Slippage${ANSI.reset} ${ANSI.white}${slippage}%${ANSI.reset}`);
|
|
583
818
|
ws.sendLine(` ${ANSI.darkGold}Mail${ANSI.reset} ${ANSI.white}${email}${ANSI.reset}`);
|
|
584
|
-
ws.sendLine(` ${ANSI.darkGold}AI${ANSI.reset} ${
|
|
819
|
+
ws.sendLine(` ${ANSI.darkGold}AI${ANSI.reset} ${hasAnyLLM() ? `${ANSI.green}● Ready${ANSI.reset}` : `${ANSI.dim}○ Not configured${ANSI.reset}`}`);
|
|
585
820
|
ws.sendLine('');
|
|
821
|
+
|
|
822
|
+
// Offer interactive config
|
|
823
|
+
ws.sendMenu('config_action', '◆ Configure', [
|
|
824
|
+
{ value: 'chain', label: '🔗 Change chain', desc: `Currently: ${chain}` },
|
|
825
|
+
{ value: 'keys', label: '🔑 LLM / API keys', desc: '' },
|
|
826
|
+
{ value: 'back', label: '← Back', desc: '' },
|
|
827
|
+
]);
|
|
828
|
+
|
|
586
829
|
return {};
|
|
587
830
|
}
|
|
588
831
|
|
|
@@ -842,6 +1085,19 @@ async function cmdKeys(args, ws) {
|
|
|
842
1085
|
ws.sendLine(` ${ANSI.green}keys add ollama http://...${ANSI.reset} ${ANSI.dim}Add Ollama host${ANSI.reset}`);
|
|
843
1086
|
ws.sendLine('');
|
|
844
1087
|
|
|
1088
|
+
// Interactive menu to add keys
|
|
1089
|
+
const llmItems = llmProviders.map(p => {
|
|
1090
|
+
const svc = SERVICES[p];
|
|
1091
|
+
const has = hasKey(p);
|
|
1092
|
+
return {
|
|
1093
|
+
value: p,
|
|
1094
|
+
label: `${has ? '✓' : '+'} ${svc.name}`,
|
|
1095
|
+
desc: has ? 'Connected — replace key' : `Add key (${svc.docsUrl})`,
|
|
1096
|
+
};
|
|
1097
|
+
});
|
|
1098
|
+
llmItems.push({ value: 'back', label: '← Back', desc: '' });
|
|
1099
|
+
ws.sendMenu('keys_provider', '◆ Add / Update API Key', llmItems);
|
|
1100
|
+
|
|
845
1101
|
const dataProviders = ['coingecko', 'dexscreener', 'alchemy', 'agentmail'];
|
|
846
1102
|
ws.sendLine(` ${ANSI.gold}Other Services:${ANSI.reset}`);
|
|
847
1103
|
for (const p of dataProviders) {
|
package/src/web/server.js
CHANGED
|
@@ -100,6 +100,48 @@ export async function startWebShell(opts = {}) {
|
|
|
100
100
|
try {
|
|
101
101
|
const msg = JSON.parse(raw.toString());
|
|
102
102
|
|
|
103
|
+
if (msg.type === 'prompt_response') {
|
|
104
|
+
// User typed a response to a prompt (e.g. API key input)
|
|
105
|
+
try {
|
|
106
|
+
const { handlePromptResponse } = await import('./commands.js');
|
|
107
|
+
const result = await handlePromptResponse(msg.id, msg.value, msg.meta || {}, {
|
|
108
|
+
send: (text) => ws.send(JSON.stringify({ type: 'output', data: text })),
|
|
109
|
+
sendLine: (text) => ws.send(JSON.stringify({ type: 'output', data: text + '\r\n' })),
|
|
110
|
+
sendMenu: (id, title, items) => ws.send(JSON.stringify({ type: 'menu', id, title, items })),
|
|
111
|
+
});
|
|
112
|
+
if (result?.output) {
|
|
113
|
+
ws.send(JSON.stringify({ type: 'output', data: result.output }));
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
ws.send(JSON.stringify({
|
|
117
|
+
type: 'output',
|
|
118
|
+
data: `\r\n \x1b[31m✗ Error: ${err.message}\x1b[0m\r\n`,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (msg.type === 'menu_select') {
|
|
125
|
+
// User selected something from an interactive menu
|
|
126
|
+
try {
|
|
127
|
+
const { handleMenuSelect } = await import('./commands.js');
|
|
128
|
+
const result = await handleMenuSelect(msg.id, msg.value, msg.item, {
|
|
129
|
+
send: (text) => ws.send(JSON.stringify({ type: 'output', data: text })),
|
|
130
|
+
sendLine: (text) => ws.send(JSON.stringify({ type: 'output', data: text + '\r\n' })),
|
|
131
|
+
sendMenu: (id, title, items) => ws.send(JSON.stringify({ type: 'menu', id, title, items })),
|
|
132
|
+
});
|
|
133
|
+
if (result?.output) {
|
|
134
|
+
ws.send(JSON.stringify({ type: 'output', data: result.output }));
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
ws.send(JSON.stringify({
|
|
138
|
+
type: 'output',
|
|
139
|
+
data: `\r\n \x1b[31m✗ Error: ${err.message}\x1b[0m\r\n`,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
103
145
|
if (msg.type === 'command') {
|
|
104
146
|
const cmd = msg.data.trim();
|
|
105
147
|
|
|
@@ -133,6 +175,7 @@ export async function startWebShell(opts = {}) {
|
|
|
133
175
|
const result = await handleCommand(cmd, {
|
|
134
176
|
send: (text) => ws.send(JSON.stringify({ type: 'output', data: text })),
|
|
135
177
|
sendLine: (text) => ws.send(JSON.stringify({ type: 'output', data: text + '\r\n' })),
|
|
178
|
+
sendMenu: (id, title, items) => ws.send(JSON.stringify({ type: 'menu', id, title, items })),
|
|
136
179
|
});
|
|
137
180
|
|
|
138
181
|
if (result?.output) {
|
package/src/web/terminal.js
CHANGED
|
@@ -4,11 +4,20 @@
|
|
|
4
4
|
|
|
5
5
|
const PROMPT = '\x1b[38;2;255;215;0m❯\x1b[0m ';
|
|
6
6
|
const PROMPT_LENGTH = 2; // visible chars: ❯ + space
|
|
7
|
+
const A = {
|
|
8
|
+
gold: '\x1b[38;2;255;215;0m',
|
|
9
|
+
dim: '\x1b[38;2;102;102;102m',
|
|
10
|
+
green: '\x1b[38;2;0;255;136m',
|
|
11
|
+
red: '\x1b[38;2;233;69;96m',
|
|
12
|
+
white: '\x1b[1;37m',
|
|
13
|
+
blue: '\x1b[38;2;68;136;255m',
|
|
14
|
+
r: '\x1b[0m',
|
|
15
|
+
};
|
|
7
16
|
|
|
8
17
|
const COMMANDS = [
|
|
9
|
-
'price', 'watch', 'gas', 'portfolio', 'history', 'market',
|
|
10
|
-
'wallet', '
|
|
11
|
-
'help', 'clear', 'banner', 'exit',
|
|
18
|
+
'ai', 'price', 'watch', 'gas', 'portfolio', 'history', 'market',
|
|
19
|
+
'wallet', 'send', 'receive', 'mail', 'keys', 'oracle', 'casino',
|
|
20
|
+
'facilitator', 'config', 'logs', 'help', 'clear', 'banner', 'exit',
|
|
12
21
|
];
|
|
13
22
|
|
|
14
23
|
let term;
|
|
@@ -18,8 +27,21 @@ let commandHistory = [];
|
|
|
18
27
|
let historyIndex = -1;
|
|
19
28
|
let connected = false;
|
|
20
29
|
|
|
30
|
+
// ── MENU STATE ────────────────────────────────
|
|
31
|
+
let menuActive = false;
|
|
32
|
+
let menuItems = [];
|
|
33
|
+
let menuIndex = 0;
|
|
34
|
+
let menuId = '';
|
|
35
|
+
let menuTitle = '';
|
|
36
|
+
|
|
37
|
+
// ── PROMPT STATE (text input) ─────────────────
|
|
38
|
+
let promptActive = false;
|
|
39
|
+
let promptId = '';
|
|
40
|
+
let promptMeta = {};
|
|
41
|
+
let promptInput = '';
|
|
42
|
+
let promptMask = false;
|
|
43
|
+
|
|
21
44
|
async function init() {
|
|
22
|
-
// Load xterm.js
|
|
23
45
|
term = new Terminal({
|
|
24
46
|
theme: {
|
|
25
47
|
background: '#0a0a1a',
|
|
@@ -64,16 +86,84 @@ async function init() {
|
|
|
64
86
|
|
|
65
87
|
window.addEventListener('resize', () => fitAddon.fit());
|
|
66
88
|
|
|
67
|
-
// Connect WebSocket
|
|
68
89
|
connectWS();
|
|
69
90
|
|
|
70
|
-
// Input handling
|
|
71
91
|
term.onKey(({ key, domEvent }) => {
|
|
72
92
|
const code = domEvent.keyCode;
|
|
73
93
|
const ctrl = domEvent.ctrlKey;
|
|
74
94
|
|
|
95
|
+
// ── PROMPT MODE (text input) ──
|
|
96
|
+
if (promptActive) {
|
|
97
|
+
if (code === 13) { // Enter — submit
|
|
98
|
+
promptActive = false;
|
|
99
|
+
term.write('\r\n');
|
|
100
|
+
if (promptInput) {
|
|
101
|
+
ws.send(JSON.stringify({
|
|
102
|
+
type: 'prompt_response',
|
|
103
|
+
id: promptId,
|
|
104
|
+
value: promptInput,
|
|
105
|
+
meta: promptMeta,
|
|
106
|
+
}));
|
|
107
|
+
} else {
|
|
108
|
+
term.write(` ${A.dim}Cancelled${A.r}\r\n\r\n`);
|
|
109
|
+
writePrompt();
|
|
110
|
+
}
|
|
111
|
+
promptInput = '';
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (code === 27 || (ctrl && code === 67)) { // Esc/Ctrl+C — cancel
|
|
115
|
+
promptActive = false;
|
|
116
|
+
promptInput = '';
|
|
117
|
+
term.write('\r\n');
|
|
118
|
+
term.write(` ${A.dim}Cancelled${A.r}\r\n\r\n`);
|
|
119
|
+
writePrompt();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (code === 8) { // Backspace
|
|
123
|
+
if (promptInput.length > 0) {
|
|
124
|
+
promptInput = promptInput.slice(0, -1);
|
|
125
|
+
term.write('\b \b');
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Printable chars
|
|
130
|
+
if (key.length === 1 && !ctrl) {
|
|
131
|
+
promptInput += key;
|
|
132
|
+
term.write(promptMask ? '●' : key);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── MENU MODE ──
|
|
138
|
+
if (menuActive) {
|
|
139
|
+
if (code === 38) { // Up
|
|
140
|
+
if (menuIndex > 0) { menuIndex--; renderMenu(); }
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (code === 40) { // Down
|
|
144
|
+
if (menuIndex < menuItems.length - 1) { menuIndex++; renderMenu(); }
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (code === 13) { // Enter — select
|
|
148
|
+
const selected = menuItems[menuIndex];
|
|
149
|
+
menuActive = false;
|
|
150
|
+
// Clear menu display
|
|
151
|
+
term.write('\x1b[?25h'); // show cursor
|
|
152
|
+
sendMenuSelection(menuId, selected);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (code === 27 || (ctrl && code === 67)) { // Esc or Ctrl+C — cancel
|
|
156
|
+
menuActive = false;
|
|
157
|
+
term.write('\r\n');
|
|
158
|
+
term.write(` ${A.dim}Cancelled${A.r}\r\n\r\n`);
|
|
159
|
+
writePrompt();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
return; // Ignore other keys in menu mode
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── NORMAL MODE ──
|
|
75
166
|
if (ctrl && code === 67) {
|
|
76
|
-
// Ctrl+C — cancel
|
|
77
167
|
currentLine = '';
|
|
78
168
|
term.write('^C\r\n');
|
|
79
169
|
writePrompt();
|
|
@@ -81,14 +171,12 @@ async function init() {
|
|
|
81
171
|
}
|
|
82
172
|
|
|
83
173
|
if (ctrl && code === 76) {
|
|
84
|
-
// Ctrl+L — clear
|
|
85
174
|
term.clear();
|
|
86
175
|
writePrompt();
|
|
87
176
|
return;
|
|
88
177
|
}
|
|
89
178
|
|
|
90
179
|
if (code === 13) {
|
|
91
|
-
// Enter
|
|
92
180
|
term.write('\r\n');
|
|
93
181
|
const cmd = currentLine.trim();
|
|
94
182
|
|
|
@@ -106,7 +194,6 @@ async function init() {
|
|
|
106
194
|
}
|
|
107
195
|
|
|
108
196
|
if (code === 8) {
|
|
109
|
-
// Backspace
|
|
110
197
|
if (currentLine.length > 0) {
|
|
111
198
|
currentLine = currentLine.slice(0, -1);
|
|
112
199
|
term.write('\b \b');
|
|
@@ -115,7 +202,6 @@ async function init() {
|
|
|
115
202
|
}
|
|
116
203
|
|
|
117
204
|
if (code === 38) {
|
|
118
|
-
// Arrow up — history
|
|
119
205
|
if (historyIndex < commandHistory.length - 1) {
|
|
120
206
|
historyIndex++;
|
|
121
207
|
replaceInput(commandHistory[historyIndex]);
|
|
@@ -124,7 +210,6 @@ async function init() {
|
|
|
124
210
|
}
|
|
125
211
|
|
|
126
212
|
if (code === 40) {
|
|
127
|
-
// Arrow down — history
|
|
128
213
|
if (historyIndex > 0) {
|
|
129
214
|
historyIndex--;
|
|
130
215
|
replaceInput(commandHistory[historyIndex]);
|
|
@@ -136,13 +221,11 @@ async function init() {
|
|
|
136
221
|
}
|
|
137
222
|
|
|
138
223
|
if (code === 9) {
|
|
139
|
-
// Tab — autocomplete
|
|
140
224
|
domEvent.preventDefault();
|
|
141
225
|
autocomplete();
|
|
142
226
|
return;
|
|
143
227
|
}
|
|
144
228
|
|
|
145
|
-
// Printable characters
|
|
146
229
|
if (key.length === 1 && !ctrl) {
|
|
147
230
|
currentLine += key;
|
|
148
231
|
term.write(key);
|
|
@@ -151,17 +234,83 @@ async function init() {
|
|
|
151
234
|
|
|
152
235
|
// Paste support
|
|
153
236
|
term.onData((data) => {
|
|
154
|
-
|
|
237
|
+
if (menuActive) return;
|
|
155
238
|
if (data.length > 1 && !data.startsWith('\x1b')) {
|
|
156
239
|
const clean = data.replace(/[\r\n]/g, '');
|
|
157
|
-
|
|
158
|
-
|
|
240
|
+
if (promptActive) {
|
|
241
|
+
promptInput += clean;
|
|
242
|
+
term.write(promptMask ? '●'.repeat(clean.length) : clean);
|
|
243
|
+
} else {
|
|
244
|
+
currentLine += clean;
|
|
245
|
+
term.write(clean);
|
|
246
|
+
}
|
|
159
247
|
}
|
|
160
248
|
});
|
|
161
249
|
|
|
162
250
|
term.focus();
|
|
163
251
|
}
|
|
164
252
|
|
|
253
|
+
// ── MENU RENDERING ────────────────────────────
|
|
254
|
+
function renderMenu() {
|
|
255
|
+
// Move cursor up to redraw (clear previous menu)
|
|
256
|
+
const totalLines = menuItems.length + 2; // title + items + hint
|
|
257
|
+
term.write(`\x1b[${totalLines}A\x1b[J`);
|
|
258
|
+
|
|
259
|
+
term.write(` ${A.gold}${menuTitle}${A.r}\r\n`);
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < menuItems.length; i++) {
|
|
262
|
+
const item = menuItems[i];
|
|
263
|
+
const label = item.label || item.value || String(item);
|
|
264
|
+
const desc = item.desc ? ` ${A.dim}${item.desc}${A.r}` : '';
|
|
265
|
+
|
|
266
|
+
if (i === menuIndex) {
|
|
267
|
+
term.write(` ${A.gold}► ${A.white}${label}${A.r}${desc}\r\n`);
|
|
268
|
+
} else {
|
|
269
|
+
term.write(` ${A.dim}${label}${A.r}${desc}\r\n`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
term.write(` ${A.dim}↑/↓ navigate • Enter select • Esc cancel${A.r}\r\n`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function showMenu(id, title, items) {
|
|
277
|
+
menuActive = true;
|
|
278
|
+
menuId = id;
|
|
279
|
+
menuTitle = title;
|
|
280
|
+
menuItems = items;
|
|
281
|
+
menuIndex = 0;
|
|
282
|
+
|
|
283
|
+
term.write('\x1b[?25l'); // hide cursor during menu
|
|
284
|
+
term.write('\r\n');
|
|
285
|
+
|
|
286
|
+
// Initial render
|
|
287
|
+
term.write(` ${A.gold}${menuTitle}${A.r}\r\n`);
|
|
288
|
+
for (let i = 0; i < menuItems.length; i++) {
|
|
289
|
+
const item = menuItems[i];
|
|
290
|
+
const label = item.label || item.value || String(item);
|
|
291
|
+
const desc = item.desc ? ` ${A.dim}${item.desc}${A.r}` : '';
|
|
292
|
+
|
|
293
|
+
if (i === menuIndex) {
|
|
294
|
+
term.write(` ${A.gold}► ${A.white}${label}${A.r}${desc}\r\n`);
|
|
295
|
+
} else {
|
|
296
|
+
term.write(` ${A.dim}${label}${A.r}${desc}\r\n`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
term.write(` ${A.dim}↑/↓ navigate • Enter select • Esc cancel${A.r}\r\n`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sendMenuSelection(id, item) {
|
|
303
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
type: 'menu_select',
|
|
306
|
+
id,
|
|
307
|
+
value: item.value || item.label || String(item),
|
|
308
|
+
item,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── WEBSOCKET ─────────────────────────────────
|
|
165
314
|
function connectWS() {
|
|
166
315
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
167
316
|
ws = new WebSocket(`${protocol}//${window.location.host}`);
|
|
@@ -177,13 +326,22 @@ function connectWS() {
|
|
|
177
326
|
|
|
178
327
|
if (msg.type === 'output') {
|
|
179
328
|
term.write(msg.data);
|
|
180
|
-
writePrompt();
|
|
329
|
+
if (!menuActive) writePrompt();
|
|
181
330
|
} else if (msg.type === 'clear') {
|
|
182
331
|
term.clear();
|
|
183
332
|
writePrompt();
|
|
333
|
+
} else if (msg.type === 'menu') {
|
|
334
|
+
showMenu(msg.id, msg.title, msg.items);
|
|
335
|
+
} else if (msg.type === 'prompt') {
|
|
336
|
+
// Server wants text input (e.g. API key)
|
|
337
|
+
promptActive = true;
|
|
338
|
+
promptId = msg.id;
|
|
339
|
+
promptMeta = { service: msg.service, ...msg };
|
|
340
|
+
promptInput = '';
|
|
341
|
+
promptMask = msg.mask || false;
|
|
342
|
+
term.write(` ${A.gold}${msg.label || 'Input:'}${A.r} `);
|
|
184
343
|
}
|
|
185
344
|
} catch {
|
|
186
|
-
// Raw text fallback
|
|
187
345
|
term.write(event.data);
|
|
188
346
|
}
|
|
189
347
|
};
|
|
@@ -191,12 +349,8 @@ function connectWS() {
|
|
|
191
349
|
ws.onclose = () => {
|
|
192
350
|
connected = false;
|
|
193
351
|
updateStatus(false);
|
|
194
|
-
term.write(
|
|
195
|
-
|
|
196
|
-
// Reconnect after 3s
|
|
197
|
-
setTimeout(() => {
|
|
198
|
-
connectWS();
|
|
199
|
-
}, 3000);
|
|
352
|
+
term.write(`\r\n${A.red} ⚡ Connection lost. Reconnecting...${A.r}\r\n`);
|
|
353
|
+
setTimeout(() => connectWS(), 3000);
|
|
200
354
|
};
|
|
201
355
|
|
|
202
356
|
ws.onerror = () => {
|
|
@@ -209,7 +363,7 @@ function sendCommand(cmd) {
|
|
|
209
363
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
210
364
|
ws.send(JSON.stringify({ type: 'command', data: cmd }));
|
|
211
365
|
} else {
|
|
212
|
-
term.write(
|
|
366
|
+
term.write(`${A.red} ✗ Not connected${A.r}\r\n`);
|
|
213
367
|
writePrompt();
|
|
214
368
|
}
|
|
215
369
|
}
|
|
@@ -219,28 +373,20 @@ function writePrompt() {
|
|
|
219
373
|
}
|
|
220
374
|
|
|
221
375
|
function replaceInput(text) {
|
|
222
|
-
// Clear current input
|
|
223
376
|
const clearLen = currentLine.length;
|
|
224
|
-
for (let i = 0; i < clearLen; i++)
|
|
225
|
-
term.write('\b \b');
|
|
226
|
-
}
|
|
377
|
+
for (let i = 0; i < clearLen; i++) term.write('\b \b');
|
|
227
378
|
currentLine = text;
|
|
228
379
|
term.write(text);
|
|
229
380
|
}
|
|
230
381
|
|
|
231
382
|
function autocomplete() {
|
|
232
383
|
if (!currentLine) return;
|
|
233
|
-
|
|
234
384
|
const matches = COMMANDS.filter((c) => c.startsWith(currentLine.toLowerCase()));
|
|
235
|
-
|
|
236
385
|
if (matches.length === 1) {
|
|
237
386
|
replaceInput(matches[0]);
|
|
238
387
|
} else if (matches.length > 1) {
|
|
239
|
-
// Show options
|
|
240
388
|
term.write('\r\n');
|
|
241
|
-
term.write(
|
|
242
|
-
matches.map((m) => ` \x1b[38;2;102;102;102m${m}\x1b[0m`).join(' ')
|
|
243
|
-
);
|
|
389
|
+
term.write(matches.map((m) => ` ${A.dim}${m}${A.r}`).join(' '));
|
|
244
390
|
term.write('\r\n');
|
|
245
391
|
writePrompt();
|
|
246
392
|
term.write(currentLine);
|
|
@@ -250,13 +396,8 @@ function autocomplete() {
|
|
|
250
396
|
function updateStatus(isConnected) {
|
|
251
397
|
const dot = document.querySelector('.status-dot');
|
|
252
398
|
const text = document.querySelector('.status-text');
|
|
253
|
-
if (dot)
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
if (text) {
|
|
257
|
-
text.textContent = isConnected ? 'Connected' : 'Disconnected';
|
|
258
|
-
}
|
|
399
|
+
if (dot) dot.className = isConnected ? 'status-dot' : 'status-dot disconnected';
|
|
400
|
+
if (text) text.textContent = isConnected ? 'Connected' : 'Disconnected';
|
|
259
401
|
}
|
|
260
402
|
|
|
261
|
-
// Boot
|
|
262
403
|
document.addEventListener('DOMContentLoaded', init);
|