rad-coder 1.0.2 → 1.0.4
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/README.md +6 -0
- package/bin/cli.js +117 -26
- package/package.json +3 -3
- package/public/test.html +3 -16
- package/server/index.js +204 -15
- package/server/tui.js +282 -0
- package/templates/AGENTS.md +29 -0
package/README.md
CHANGED
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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rad-coder",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Development environment for testing ResponsiveAds creative custom JS with hot-reload",
|
|
5
5
|
"bin": {
|
|
6
|
-
"rad-coder": "
|
|
6
|
+
"rad-coder": "bin/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"dev": "node bin/cli.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"repository": {
|
|
28
28
|
"type": "git",
|
|
29
|
-
"url": "https://github.com/ResponsiveAds/rad-coder"
|
|
29
|
+
"url": "git+https://github.com/ResponsiveAds/rad-coder.git"
|
|
30
30
|
},
|
|
31
31
|
"author": "ResponsiveAds",
|
|
32
32
|
"license": "MIT",
|
package/public/test.html
CHANGED
|
@@ -87,23 +87,8 @@
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
.ad-container {
|
|
90
|
-
padding: 20px;
|
|
91
|
-
display: flex;
|
|
92
|
-
justify-content: center;
|
|
93
|
-
align-items: flex-start;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
.ad-wrapper {
|
|
97
|
-
background: #fff;
|
|
98
|
-
border-radius: 8px;
|
|
99
|
-
overflow: hidden;
|
|
100
|
-
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
|
101
90
|
}
|
|
102
91
|
|
|
103
|
-
.inner-content {
|
|
104
|
-
width: 100%;
|
|
105
|
-
min-height: 300px;
|
|
106
|
-
}
|
|
107
92
|
|
|
108
93
|
/* Toast notification for reload */
|
|
109
94
|
.toast {
|
|
@@ -180,6 +165,8 @@
|
|
|
180
165
|
document.getElementById('creative-id').textContent = 'Loading...';
|
|
181
166
|
|
|
182
167
|
const config = await fetchConfigWithRetry();
|
|
168
|
+
//not sure why we get URL that does not work https://edit.responsiveads.com/flowlines
|
|
169
|
+
config.flSource = 'https://s3-eu-west-1.amazonaws.com/publish.responsiveads.com/flowlines/';
|
|
183
170
|
|
|
184
171
|
// Update UI
|
|
185
172
|
document.getElementById('creative-id').textContent = `ID: ${config.creativeId}`;
|
|
@@ -231,7 +218,7 @@
|
|
|
231
218
|
tracking: false,
|
|
232
219
|
isFluid: config.isFluid,
|
|
233
220
|
screenshot: false,
|
|
234
|
-
crossOrigin:
|
|
221
|
+
crossOrigin: false,
|
|
235
222
|
flSource: config.flSource,
|
|
236
223
|
config: {
|
|
237
224
|
_default: {
|
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;
|
package/templates/AGENTS.md
CHANGED
|
@@ -4,6 +4,9 @@ You are an agent writing only JS for a responsive creative. The creative was bui
|
|
|
4
4
|
|
|
5
5
|
To do this you can only edit the `custom.js` file in this directory. When you edit and save this file the creative will be automatically loaded on the test page: http://localhost:3000/test.html. The code you wrote in `custom.js` will be applied to the creative.
|
|
6
6
|
|
|
7
|
+
Use the http://localhost:3000/test.html URL to open creative in the browser. Inspect the HTML dom so that you can use IDs from elements inside the custom.js code. Alo use the browser to test the code and make sure there are no console.log errors.
|
|
8
|
+
|
|
9
|
+
|
|
7
10
|
Use modern JS standards and code practices.
|
|
8
11
|
|
|
9
12
|
We can use custom in situations when we want to add extra interactivity to our responsive creative. Use the Radical API to access elements added from the editor, update their behavior, and add custom functionalities to your ad.
|
|
@@ -16,6 +19,32 @@ You can use all available JavaScript functions to manipulate element position an
|
|
|
16
19
|
|
|
17
20
|
This document outlines the specific implementation patterns and lifecycle hooks for the Radical API. Use this guide to programmatically control elements, manage dynamic data (DCO), and handle cross-window interactions in ResponsiveAds creatives.
|
|
18
21
|
|
|
22
|
+
Use this as a good starting point always call functions from `!rad.getMergedContent().inEditor` check to prevent messing up the editor code.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
var rad = Radical.getAdByWindow(window);
|
|
26
|
+
var container = rad.getContainer();
|
|
27
|
+
|
|
28
|
+
var inScreenshot = window.location.href.indexOf('preview?screenshot=1') > -1 ? true : false;
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if (!rad.getMergedContent().inEditor) {
|
|
32
|
+
rad.onLoad(onAdLoaded);
|
|
33
|
+
rad.onBeforeRender(onBeforeRender);
|
|
34
|
+
rad.onRender(onAdRender);
|
|
35
|
+
|
|
36
|
+
}
|
|
37
|
+
function onAdLoaded() {}
|
|
38
|
+
function onBeforeRender(arg) {
|
|
39
|
+
console.log('onBeforeRender', arg);
|
|
40
|
+
}
|
|
41
|
+
function onAdRender() {
|
|
42
|
+
console.log('onAdRender');
|
|
43
|
+
const el = rad.getElementById('a2');
|
|
44
|
+
console.log(el);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
19
48
|
## 1. Initializing the Controller
|
|
20
49
|
|
|
21
50
|
Every script must first reference the ad instance and its container.
|