rad-coder 1.0.2 → 1.0.3
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/bin/cli.js +117 -26
- package/package.json +1 -1
- package/server/index.js +204 -15
- package/server/tui.js +282 -0
package/bin/cli.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const readline = require('readline');
|
|
6
5
|
|
|
7
6
|
// Get the package root directory
|
|
8
7
|
const packageRoot = path.join(__dirname, '..');
|
|
@@ -15,50 +14,142 @@ function extractCreativeId(input) {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
* Prompt user with a question and choices
|
|
17
|
+
* Prompt user with a question and arrow-key selectable choices
|
|
19
18
|
* @param {string} question - The question to ask
|
|
20
19
|
* @param {string[]} choices - Array of choices
|
|
21
20
|
* @returns {Promise<number>} - The index of the selected choice (0-based)
|
|
22
21
|
*/
|
|
23
22
|
function promptUser(question, choices) {
|
|
24
23
|
return new Promise((resolve) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
rl.close();
|
|
40
|
-
resolve(num - 1);
|
|
24
|
+
let selected = 0;
|
|
25
|
+
|
|
26
|
+
// Print question
|
|
27
|
+
console.log(`\n${question}\n`);
|
|
28
|
+
|
|
29
|
+
function draw() {
|
|
30
|
+
// Move cursor up to overwrite previous menu lines
|
|
31
|
+
if (selected !== -1) {
|
|
32
|
+
process.stdout.write(`\x1B[${choices.length}A`);
|
|
33
|
+
}
|
|
34
|
+
for (let i = 0; i < choices.length; i++) {
|
|
35
|
+
process.stdout.write('\x1B[2K'); // clear line
|
|
36
|
+
if (i === selected) {
|
|
37
|
+
process.stdout.write(` \x1B[36m\x1B[1m❯ ${choices[i]}\x1B[0m\n`);
|
|
41
38
|
} else {
|
|
42
|
-
|
|
43
|
-
ask();
|
|
39
|
+
process.stdout.write(` ${choices[i]}\n`);
|
|
44
40
|
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Initial draw (set selected to -1 so it doesn't move cursor up first time)
|
|
45
|
+
const initial = selected;
|
|
46
|
+
selected = -1;
|
|
47
|
+
// Print placeholder lines first
|
|
48
|
+
for (let i = 0; i < choices.length; i++) {
|
|
49
|
+
process.stdout.write('\n');
|
|
50
|
+
}
|
|
51
|
+
// Move back up and draw properly
|
|
52
|
+
process.stdout.write(`\x1B[${choices.length}A`);
|
|
53
|
+
selected = initial;
|
|
54
|
+
draw();
|
|
55
|
+
|
|
56
|
+
// Enable raw mode for keypress detection
|
|
57
|
+
if (process.stdin.isTTY) {
|
|
58
|
+
process.stdin.setRawMode(true);
|
|
59
|
+
}
|
|
60
|
+
process.stdin.resume();
|
|
61
|
+
|
|
62
|
+
function onData(data) {
|
|
63
|
+
const key = data.toString();
|
|
64
|
+
|
|
65
|
+
// Ctrl+C
|
|
66
|
+
if (key === '\x03') {
|
|
67
|
+
cleanup();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Up arrow
|
|
73
|
+
if (key === '\x1B[A') {
|
|
74
|
+
selected = Math.max(0, selected - 1);
|
|
75
|
+
draw();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Down arrow
|
|
80
|
+
if (key === '\x1B[B') {
|
|
81
|
+
selected = Math.min(choices.length - 1, selected + 1);
|
|
82
|
+
draw();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
47
85
|
|
|
48
|
-
|
|
86
|
+
// Enter
|
|
87
|
+
if (key === '\r' || key === '\n') {
|
|
88
|
+
cleanup();
|
|
89
|
+
resolve(selected);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Number keys for quick select
|
|
94
|
+
const num = parseInt(key, 10);
|
|
95
|
+
if (num >= 1 && num <= choices.length) {
|
|
96
|
+
selected = num - 1;
|
|
97
|
+
draw();
|
|
98
|
+
cleanup();
|
|
99
|
+
resolve(selected);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cleanup() {
|
|
105
|
+
process.stdin.removeListener('data', onData);
|
|
106
|
+
if (process.stdin.isTTY) {
|
|
107
|
+
process.stdin.setRawMode(false);
|
|
108
|
+
}
|
|
109
|
+
process.stdin.pause();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.stdin.on('data', onData);
|
|
49
113
|
});
|
|
50
114
|
}
|
|
51
115
|
|
|
52
|
-
// Get creative ID from command line argument
|
|
53
|
-
const
|
|
116
|
+
// Get creative ID from command line argument (skip flags)
|
|
117
|
+
const args = process.argv.slice(2);
|
|
118
|
+
let input = null;
|
|
119
|
+
let editorFlag = null;
|
|
120
|
+
let noEditor = false;
|
|
121
|
+
|
|
122
|
+
for (const arg of args) {
|
|
123
|
+
if (arg.startsWith('--editor=')) {
|
|
124
|
+
editorFlag = arg.split('=')[1];
|
|
125
|
+
} else if (arg === '--no-editor') {
|
|
126
|
+
noEditor = true;
|
|
127
|
+
} else if (!arg.startsWith('--')) {
|
|
128
|
+
input = arg;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pass editor preferences via environment variables
|
|
133
|
+
if (editorFlag) {
|
|
134
|
+
process.env.RAD_CODER_EDITOR = editorFlag;
|
|
135
|
+
}
|
|
136
|
+
if (noEditor) {
|
|
137
|
+
process.env.RAD_CODER_NO_EDITOR = '1';
|
|
138
|
+
}
|
|
139
|
+
|
|
54
140
|
const creativeId = extractCreativeId(input);
|
|
55
141
|
|
|
56
142
|
if (!creativeId) {
|
|
57
|
-
console.log('Usage: npx rad-coder <creativeId or previewUrl>');
|
|
143
|
+
console.log('Usage: npx rad-coder <creativeId or previewUrl> [options]');
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log('Options:');
|
|
146
|
+
console.log(' --editor=<cmd> Set code editor command (default: code)');
|
|
147
|
+
console.log(' --no-editor Don\'t auto-open code editor');
|
|
58
148
|
console.log('');
|
|
59
149
|
console.log('Examples:');
|
|
60
150
|
console.log(' npx rad-coder 697b80fcc6e904025f5147a0');
|
|
61
151
|
console.log(' npx rad-coder https://studio.responsiveads.com/creatives/697b80fcc6e904025f5147a0/preview');
|
|
152
|
+
console.log(' npx rad-coder 697b80fcc6e904025f5147a0 --editor=cursor');
|
|
62
153
|
process.exit(1);
|
|
63
154
|
}
|
|
64
155
|
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -5,6 +5,50 @@ const chokidar = require('chokidar');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const http = require('http');
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const TUI = require('./tui');
|
|
10
|
+
|
|
11
|
+
// ============================================================
|
|
12
|
+
// TUI Instance & Logging
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
let tui = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Log a message — routes to TUI scroll area when active, otherwise console.log
|
|
19
|
+
*/
|
|
20
|
+
function log(message) {
|
|
21
|
+
if (tui && !tui.destroyed) {
|
|
22
|
+
tui.log(message);
|
|
23
|
+
} else {
|
|
24
|
+
console.log(message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open the user's code editor
|
|
30
|
+
*/
|
|
31
|
+
function openEditor() {
|
|
32
|
+
const editorCmd = process.env.RAD_CODER_EDITOR
|
|
33
|
+
|| process.env.VISUAL
|
|
34
|
+
|| process.env.EDITOR
|
|
35
|
+
|| 'code';
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const child = spawn(editorCmd, [userDir], {
|
|
39
|
+
detached: true,
|
|
40
|
+
stdio: 'ignore',
|
|
41
|
+
shell: process.platform === 'win32'
|
|
42
|
+
});
|
|
43
|
+
child.unref();
|
|
44
|
+
child.on('error', (err) => {
|
|
45
|
+
log(` ✗ Could not open editor "${editorCmd}": ${err.message}`);
|
|
46
|
+
});
|
|
47
|
+
log(` Editor (${editorCmd}) opened`);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
log(` ✗ Could not open editor "${editorCmd}": ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
8
52
|
|
|
9
53
|
// ============================================================
|
|
10
54
|
// Directory Configuration
|
|
@@ -113,8 +157,8 @@ function extractJsonObject(html, startMarker) {
|
|
|
113
157
|
async function fetchCreativeConfig(creativeId) {
|
|
114
158
|
const previewUrl = `https://studio.responsiveads.com/creatives/${creativeId}/preview`;
|
|
115
159
|
|
|
116
|
-
|
|
117
|
-
|
|
160
|
+
log(` Fetching creative config from studio...`);
|
|
161
|
+
log(` URL: ${previewUrl}\n`);
|
|
118
162
|
|
|
119
163
|
try {
|
|
120
164
|
const response = await fetch(previewUrl);
|
|
@@ -261,7 +305,7 @@ async function fetchCreativeConfig(creativeId) {
|
|
|
261
305
|
};
|
|
262
306
|
|
|
263
307
|
} catch (error) {
|
|
264
|
-
|
|
308
|
+
log(`\n ✗ Failed to fetch creative config: ${error.message}\n`);
|
|
265
309
|
process.exit(1);
|
|
266
310
|
}
|
|
267
311
|
}
|
|
@@ -281,11 +325,11 @@ const clients = new Set();
|
|
|
281
325
|
|
|
282
326
|
wss.on('connection', (ws) => {
|
|
283
327
|
clients.add(ws);
|
|
284
|
-
|
|
328
|
+
log(' Browser connected for hot-reload');
|
|
285
329
|
|
|
286
330
|
ws.on('close', () => {
|
|
287
331
|
clients.delete(ws);
|
|
288
|
-
|
|
332
|
+
log(' Browser disconnected');
|
|
289
333
|
});
|
|
290
334
|
});
|
|
291
335
|
|
|
@@ -341,14 +385,14 @@ const watcher = chokidar.watch(customJsWatchPath, {
|
|
|
341
385
|
ignoreInitial: true
|
|
342
386
|
});
|
|
343
387
|
|
|
344
|
-
watcher.on('change', (
|
|
345
|
-
|
|
346
|
-
|
|
388
|
+
watcher.on('change', (changedPath) => {
|
|
389
|
+
log(` File changed: ${path.basename(changedPath)}`);
|
|
390
|
+
log(' Reloading browsers...');
|
|
347
391
|
broadcastReload();
|
|
348
392
|
});
|
|
349
393
|
|
|
350
394
|
watcher.on('error', (error) => {
|
|
351
|
-
|
|
395
|
+
log(` Watcher error: ${error.message}`);
|
|
352
396
|
});
|
|
353
397
|
|
|
354
398
|
// ============================================================
|
|
@@ -390,7 +434,6 @@ async function start(prefetchedConfig = null) {
|
|
|
390
434
|
console.log(` Test page: http://${host}:${port}/test.html`);
|
|
391
435
|
console.log(`\n Working directory: ${userDir}`);
|
|
392
436
|
console.log(' Edit custom.js and save to hot-reload\n');
|
|
393
|
-
console.log(' Press Ctrl+C to stop\n');
|
|
394
437
|
|
|
395
438
|
// Small delay to ensure server is fully ready before opening browser
|
|
396
439
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
@@ -399,11 +442,152 @@ async function start(prefetchedConfig = null) {
|
|
|
399
442
|
try {
|
|
400
443
|
const open = (await import('open')).default;
|
|
401
444
|
await open(`http://${host}:${port}/test.html`);
|
|
402
|
-
console.log(' Browser opened automatically
|
|
445
|
+
console.log(' Browser opened automatically');
|
|
403
446
|
} catch (err) {
|
|
404
|
-
console.log(
|
|
405
|
-
console.log(` Please open http://${host}:${port}/test.html manually
|
|
447
|
+
console.log(` Could not auto-open browser: ${err.message}`);
|
|
448
|
+
console.log(` Please open http://${host}:${port}/test.html manually`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Auto-open editor (unless --no-editor)
|
|
452
|
+
if (!process.env.RAD_CODER_NO_EDITOR) {
|
|
453
|
+
openEditor();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Start interactive TUI (only if stdin is a TTY)
|
|
457
|
+
if (process.stdin.isTTY) {
|
|
458
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
459
|
+
startInteractiveMenu();
|
|
460
|
+
} else {
|
|
461
|
+
console.log(' Press Ctrl+C to stop\n');
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ============================================================
|
|
467
|
+
// Interactive Menu
|
|
468
|
+
// ============================================================
|
|
469
|
+
|
|
470
|
+
function getMainMenuItems() {
|
|
471
|
+
const items = [
|
|
472
|
+
{ label: 'Open Browser', id: 'open-browser' },
|
|
473
|
+
{ label: 'Open Editor', id: 'open-editor' },
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
if (creativeConfig && creativeConfig.allFlowlines.length > 1) {
|
|
477
|
+
items.push({ label: 'Switch Flowline', id: 'switch-flowline', description: `(${creativeConfig.flowlineName})` });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
items.push(
|
|
481
|
+
{ label: 'Server Status', id: 'status' },
|
|
482
|
+
{ label: 'Clear Logs', id: 'clear' },
|
|
483
|
+
{ label: 'Restart Server', id: 'restart' },
|
|
484
|
+
{ label: 'Stop Server', id: 'stop' },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return items;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function getFlowlineMenuItems() {
|
|
491
|
+
const items = [{ label: '← Back', id: 'back' }];
|
|
492
|
+
creativeConfig.allFlowlines.forEach((fl, i) => {
|
|
493
|
+
const marker = fl.id === creativeConfig.flowlineId ? ' ✓' : '';
|
|
494
|
+
items.push({ label: `${fl.name}${marker}`, id: `flowline-${i}`, flowlineIndex: i });
|
|
495
|
+
});
|
|
496
|
+
return items;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function startInteractiveMenu() {
|
|
500
|
+
tui = new TUI();
|
|
501
|
+
|
|
502
|
+
let inSubMenu = false;
|
|
503
|
+
|
|
504
|
+
function handleSelect(item) {
|
|
505
|
+
// Sub-menu: flowline selection
|
|
506
|
+
if (inSubMenu) {
|
|
507
|
+
if (item.id === 'back') {
|
|
508
|
+
inSubMenu = false;
|
|
509
|
+
tui.updateMenu(getMainMenuItems());
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// Switch flowline
|
|
513
|
+
const fl = creativeConfig.allFlowlines[item.flowlineIndex];
|
|
514
|
+
if (fl) {
|
|
515
|
+
creativeConfig.flowlineId = fl.id;
|
|
516
|
+
creativeConfig.flowlineName = fl.name;
|
|
517
|
+
creativeConfig.sizes = fl.sizes || [];
|
|
518
|
+
creativeConfig.isFluid = fl.isFluid || false;
|
|
519
|
+
log(` Switched to flowline: ${fl.name}`);
|
|
520
|
+
broadcastReload();
|
|
521
|
+
}
|
|
522
|
+
inSubMenu = false;
|
|
523
|
+
tui.updateMenu(getMainMenuItems());
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Main menu actions
|
|
528
|
+
switch (item.id) {
|
|
529
|
+
case 'open-browser': {
|
|
530
|
+
const { port, host } = creativeConfig.server;
|
|
531
|
+
import('open').then(mod => {
|
|
532
|
+
mod.default(`http://${host}:${port}/test.html`);
|
|
533
|
+
log(' Browser opened');
|
|
534
|
+
}).catch(err => {
|
|
535
|
+
log(` Could not open browser: ${err.message}`);
|
|
536
|
+
});
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
case 'open-editor':
|
|
541
|
+
openEditor();
|
|
542
|
+
break;
|
|
543
|
+
|
|
544
|
+
case 'switch-flowline':
|
|
545
|
+
inSubMenu = true;
|
|
546
|
+
tui.updateMenu(getFlowlineMenuItems());
|
|
547
|
+
break;
|
|
548
|
+
|
|
549
|
+
case 'status': {
|
|
550
|
+
const { port, host } = creativeConfig.server;
|
|
551
|
+
log('');
|
|
552
|
+
log(' ── Server Status ──────────────────');
|
|
553
|
+
log(` Creative ID : ${creativeConfig.creativeId}`);
|
|
554
|
+
log(` Flowline : ${creativeConfig.flowlineName}`);
|
|
555
|
+
log(` Flowline ID : ${creativeConfig.flowlineId}`);
|
|
556
|
+
log(` Sizes : ${creativeConfig.sizes.join(', ') || 'N/A'}`);
|
|
557
|
+
log(` Is Fluid : ${creativeConfig.isFluid}`);
|
|
558
|
+
log(` Server : http://${host}:${port}`);
|
|
559
|
+
log(` Directory : ${userDir}`);
|
|
560
|
+
log(` Browsers : ${clients.size} connected`);
|
|
561
|
+
log(' ───────────────────────────────────');
|
|
562
|
+
log('');
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
case 'clear':
|
|
567
|
+
tui.clearLogs();
|
|
568
|
+
break;
|
|
569
|
+
|
|
570
|
+
case 'restart': {
|
|
571
|
+
log(' Restarting server...');
|
|
572
|
+
const { port, host } = creativeConfig.server;
|
|
573
|
+
server.close(() => {
|
|
574
|
+
server.listen(port, host, () => {
|
|
575
|
+
log(` Server restarted on http://${host}:${port}`);
|
|
576
|
+
broadcastReload();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case 'stop':
|
|
583
|
+
gracefulShutdown();
|
|
584
|
+
break;
|
|
406
585
|
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
tui.init({
|
|
589
|
+
menuItems: getMainMenuItems(),
|
|
590
|
+
onSelect: handleSelect,
|
|
407
591
|
});
|
|
408
592
|
}
|
|
409
593
|
|
|
@@ -423,7 +607,10 @@ if (!isModule) {
|
|
|
423
607
|
}
|
|
424
608
|
|
|
425
609
|
// Graceful shutdown
|
|
426
|
-
|
|
610
|
+
function gracefulShutdown() {
|
|
611
|
+
if (tui) {
|
|
612
|
+
tui.destroy();
|
|
613
|
+
}
|
|
427
614
|
console.log('\n Shutting down...');
|
|
428
615
|
watcher.close();
|
|
429
616
|
wss.close();
|
|
@@ -431,4 +618,6 @@ process.on('SIGINT', () => {
|
|
|
431
618
|
console.log(' Server stopped\n');
|
|
432
619
|
process.exit(0);
|
|
433
620
|
});
|
|
434
|
-
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
process.on('SIGINT', gracefulShutdown);
|
package/server/tui.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI Module
|
|
3
|
+
*
|
|
4
|
+
* Provides an interactive arrow-key menu pinned at the bottom of the terminal
|
|
5
|
+
* with a scrolling log area above it. Uses raw ANSI escape codes — zero dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
|
|
10
|
+
// ANSI escape helpers
|
|
11
|
+
const ESC = '\x1B';
|
|
12
|
+
const CSI = `${ESC}[`;
|
|
13
|
+
|
|
14
|
+
const ansi = {
|
|
15
|
+
clearScreen: `${CSI}2J`,
|
|
16
|
+
clearLine: `${CSI}2K`,
|
|
17
|
+
cursorTo: (row, col) => `${CSI}${row};${col}H`,
|
|
18
|
+
cursorSave: `${ESC}7`,
|
|
19
|
+
cursorRestore: `${ESC}8`,
|
|
20
|
+
scrollRegion: (top, bottom) => `${CSI}${top};${bottom}r`,
|
|
21
|
+
resetScrollRegion: `${CSI}r`,
|
|
22
|
+
showCursor: `${CSI}?25h`,
|
|
23
|
+
hideCursor: `${CSI}?25l`,
|
|
24
|
+
bold: `${CSI}1m`,
|
|
25
|
+
dim: `${CSI}2m`,
|
|
26
|
+
cyan: `${CSI}36m`,
|
|
27
|
+
green: `${CSI}32m`,
|
|
28
|
+
yellow: `${CSI}33m`,
|
|
29
|
+
inverse: `${CSI}7m`,
|
|
30
|
+
reset: `${CSI}0m`,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
class TUI {
|
|
34
|
+
constructor() {
|
|
35
|
+
this.menuItems = [];
|
|
36
|
+
this.selectedIndex = 0;
|
|
37
|
+
this.onSelect = null;
|
|
38
|
+
this.destroyed = false;
|
|
39
|
+
this.menuHeight = 0; // computed from items + header + border lines
|
|
40
|
+
this._boundKeyHandler = this._handleKeypress.bind(this);
|
|
41
|
+
this._boundResize = this._handleResize.bind(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Initialize the TUI
|
|
46
|
+
* @param {Object} options
|
|
47
|
+
* @param {Array<{label: string, description?: string}>} options.menuItems
|
|
48
|
+
* @param {Function} options.onSelect - callback(item, index)
|
|
49
|
+
*/
|
|
50
|
+
init(options) {
|
|
51
|
+
this.menuItems = options.menuItems || [];
|
|
52
|
+
this.onSelect = options.onSelect || (() => {});
|
|
53
|
+
this.selectedIndex = 0;
|
|
54
|
+
this.menuHeight = this.menuItems.length + 3; // items + header + top/bottom borders
|
|
55
|
+
|
|
56
|
+
// Set raw mode for keypress detection
|
|
57
|
+
if (process.stdin.isTTY) {
|
|
58
|
+
process.stdin.setRawMode(true);
|
|
59
|
+
process.stdin.resume();
|
|
60
|
+
process.stdin.on('data', this._boundKeyHandler);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle terminal resize
|
|
64
|
+
process.stdout.on('resize', this._boundResize);
|
|
65
|
+
|
|
66
|
+
// Initial draw
|
|
67
|
+
this._setupScreen();
|
|
68
|
+
this._drawMenu();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set up ANSI scrolling region (log area = top, menu = bottom)
|
|
73
|
+
*/
|
|
74
|
+
_setupScreen() {
|
|
75
|
+
const rows = process.stdout.rows || 24;
|
|
76
|
+
const logBottom = rows - this.menuHeight;
|
|
77
|
+
|
|
78
|
+
// Set scrolling region to the top portion only
|
|
79
|
+
process.stdout.write(ansi.scrollRegion(1, logBottom));
|
|
80
|
+
|
|
81
|
+
// Position cursor in the log area
|
|
82
|
+
process.stdout.write(ansi.cursorTo(logBottom, 1));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Handle terminal resize
|
|
87
|
+
*/
|
|
88
|
+
_handleResize() {
|
|
89
|
+
if (this.destroyed) return;
|
|
90
|
+
this._setupScreen();
|
|
91
|
+
this._drawMenu();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handle raw keypress data
|
|
96
|
+
*/
|
|
97
|
+
_handleKeypress(data) {
|
|
98
|
+
if (this.destroyed) return;
|
|
99
|
+
|
|
100
|
+
const key = data.toString();
|
|
101
|
+
|
|
102
|
+
// Ctrl+C
|
|
103
|
+
if (key === '\x03') {
|
|
104
|
+
this.destroy();
|
|
105
|
+
process.emit('SIGINT');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Up arrow
|
|
110
|
+
if (key === `${CSI}A`) {
|
|
111
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
112
|
+
this._drawMenu();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Down arrow
|
|
117
|
+
if (key === `${CSI}B`) {
|
|
118
|
+
this.selectedIndex = Math.min(this.menuItems.length - 1, this.selectedIndex + 1);
|
|
119
|
+
this._drawMenu();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Enter
|
|
124
|
+
if (key === '\r' || key === '\n') {
|
|
125
|
+
const item = this.menuItems[this.selectedIndex];
|
|
126
|
+
if (item && this.onSelect) {
|
|
127
|
+
this.onSelect(item, this.selectedIndex);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Number keys 1-9 for quick select
|
|
133
|
+
const num = parseInt(key, 10);
|
|
134
|
+
if (num >= 1 && num <= this.menuItems.length) {
|
|
135
|
+
this.selectedIndex = num - 1;
|
|
136
|
+
this._drawMenu();
|
|
137
|
+
const item = this.menuItems[this.selectedIndex];
|
|
138
|
+
if (item && this.onSelect) {
|
|
139
|
+
this.onSelect(item, this.selectedIndex);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Draw the menu at the bottom of the terminal
|
|
147
|
+
*/
|
|
148
|
+
_drawMenu() {
|
|
149
|
+
if (this.destroyed) return;
|
|
150
|
+
|
|
151
|
+
const rows = process.stdout.rows || 24;
|
|
152
|
+
const cols = process.stdout.cols || 80;
|
|
153
|
+
const menuStartRow = rows - this.menuHeight + 1;
|
|
154
|
+
|
|
155
|
+
// Save cursor position (in log area)
|
|
156
|
+
let output = ansi.cursorSave;
|
|
157
|
+
output += ansi.hideCursor;
|
|
158
|
+
|
|
159
|
+
// Draw top border
|
|
160
|
+
output += ansi.cursorTo(menuStartRow, 1);
|
|
161
|
+
output += ansi.clearLine;
|
|
162
|
+
output += `${ansi.dim}${'─'.repeat(Math.min(cols, 60))}${ansi.reset}`;
|
|
163
|
+
|
|
164
|
+
// Draw header
|
|
165
|
+
output += ansi.cursorTo(menuStartRow + 1, 1);
|
|
166
|
+
output += ansi.clearLine;
|
|
167
|
+
output += `${ansi.bold} ↑↓ Navigate Enter Select 1-${this.menuItems.length} Quick Select${ansi.reset}`;
|
|
168
|
+
|
|
169
|
+
// Draw menu items
|
|
170
|
+
for (let i = 0; i < this.menuItems.length; i++) {
|
|
171
|
+
const row = menuStartRow + 2 + i;
|
|
172
|
+
const item = this.menuItems[i];
|
|
173
|
+
const isSelected = i === this.selectedIndex;
|
|
174
|
+
|
|
175
|
+
output += ansi.cursorTo(row, 1);
|
|
176
|
+
output += ansi.clearLine;
|
|
177
|
+
|
|
178
|
+
if (isSelected) {
|
|
179
|
+
output += `${ansi.cyan}${ansi.bold} ❯ ${item.label}${ansi.reset}`;
|
|
180
|
+
if (item.description) {
|
|
181
|
+
output += `${ansi.dim} ${item.description}${ansi.reset}`;
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
output += ` ${item.label}`;
|
|
185
|
+
if (item.description) {
|
|
186
|
+
output += `${ansi.dim} ${item.description}${ansi.reset}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Restore cursor position (back to log area)
|
|
192
|
+
output += ansi.cursorRestore;
|
|
193
|
+
output += ansi.showCursor;
|
|
194
|
+
|
|
195
|
+
process.stdout.write(output);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Update menu items (e.g., for sub-menus)
|
|
200
|
+
*/
|
|
201
|
+
updateMenu(items) {
|
|
202
|
+
const oldHeight = this.menuHeight;
|
|
203
|
+
this.menuItems = items;
|
|
204
|
+
this.selectedIndex = 0;
|
|
205
|
+
this.menuHeight = items.length + 3;
|
|
206
|
+
|
|
207
|
+
if (this.menuHeight !== oldHeight) {
|
|
208
|
+
// Clear old menu area
|
|
209
|
+
const rows = process.stdout.rows || 24;
|
|
210
|
+
const oldMenuStart = rows - oldHeight + 1;
|
|
211
|
+
let clear = '';
|
|
212
|
+
for (let i = oldMenuStart; i <= rows; i++) {
|
|
213
|
+
clear += ansi.cursorTo(i, 1) + ansi.clearLine;
|
|
214
|
+
}
|
|
215
|
+
process.stdout.write(clear);
|
|
216
|
+
|
|
217
|
+
// Reconfigure scroll region
|
|
218
|
+
this._setupScreen();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this._drawMenu();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Add a log message to the scrolling log area
|
|
226
|
+
*/
|
|
227
|
+
log(message) {
|
|
228
|
+
if (this.destroyed) {
|
|
229
|
+
console.log(message);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const rows = process.stdout.rows || 24;
|
|
234
|
+
const logBottom = rows - this.menuHeight;
|
|
235
|
+
|
|
236
|
+
// Save cursor, move to bottom of log area, print message (scrolls within region)
|
|
237
|
+
let output = ansi.cursorSave;
|
|
238
|
+
output += ansi.cursorTo(logBottom, 1);
|
|
239
|
+
output += '\n' + message;
|
|
240
|
+
output += ansi.cursorRestore;
|
|
241
|
+
|
|
242
|
+
process.stdout.write(output);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Clear the log area
|
|
247
|
+
*/
|
|
248
|
+
clearLogs() {
|
|
249
|
+
const rows = process.stdout.rows || 24;
|
|
250
|
+
const logBottom = rows - this.menuHeight;
|
|
251
|
+
|
|
252
|
+
let output = '';
|
|
253
|
+
for (let i = 1; i <= logBottom; i++) {
|
|
254
|
+
output += ansi.cursorTo(i, 1) + ansi.clearLine;
|
|
255
|
+
}
|
|
256
|
+
output += ansi.cursorTo(1, 1);
|
|
257
|
+
process.stdout.write(output);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Destroy the TUI and restore terminal state
|
|
262
|
+
*/
|
|
263
|
+
destroy() {
|
|
264
|
+
if (this.destroyed) return;
|
|
265
|
+
this.destroyed = true;
|
|
266
|
+
|
|
267
|
+
// Remove listeners
|
|
268
|
+
process.stdin.removeListener('data', this._boundKeyHandler);
|
|
269
|
+
process.stdout.removeListener('resize', this._boundResize);
|
|
270
|
+
|
|
271
|
+
// Restore terminal
|
|
272
|
+
process.stdout.write(ansi.resetScrollRegion);
|
|
273
|
+
process.stdout.write(ansi.showCursor);
|
|
274
|
+
|
|
275
|
+
if (process.stdin.isTTY) {
|
|
276
|
+
process.stdin.setRawMode(false);
|
|
277
|
+
}
|
|
278
|
+
process.stdin.pause();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = TUI;
|