bunosh 0.5.9 β†’ 0.6.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.
package/bunosh.js CHANGED
@@ -106,6 +106,39 @@ async function loadBunoshfiles(tasksFile) {
106
106
 
107
107
  async function main() {
108
108
 
109
+ const serverIndex = process.argv.indexOf('-S');
110
+ if (serverIndex !== -1) {
111
+ let serverAddress = '127.0.0.1:7171';
112
+
113
+ if (serverIndex + 1 < process.argv.length && !process.argv[serverIndex + 1].startsWith('-')) {
114
+ serverAddress = process.argv[serverIndex + 1];
115
+ }
116
+
117
+ let host = '127.0.0.1';
118
+ let port = 7171;
119
+
120
+ if (serverAddress.startsWith('http://') || serverAddress.startsWith('https://')) {
121
+ try {
122
+ const url = new URL(serverAddress);
123
+ host = url.hostname;
124
+ port = parseInt(url.port) || 7171;
125
+ } catch (error) {
126
+ console.error('Invalid URL format:', serverAddress);
127
+ process.exit(1);
128
+ }
129
+ } else if (serverAddress.includes(':')) {
130
+ [host, port] = serverAddress.split(':');
131
+ port = parseInt(port);
132
+ } else if (!isNaN(parseInt(serverAddress))) {
133
+ port = parseInt(serverAddress);
134
+ }
135
+
136
+ const BunoshWebServer = (await import('./src/server.js')).default;
137
+ const server = new BunoshWebServer(port, host);
138
+ await server.start();
139
+ return;
140
+ }
141
+
109
142
  const bunoshfileIndex = process.argv.indexOf('--bunoshfile');
110
143
  let customBunoshfile = null;
111
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunosh",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "Task runner that turns JavaScript functions into CLI commands. Runs on Bun and Node.js.",
5
5
  "type": "module",
6
6
  "module": "index.js",
@@ -37,6 +37,7 @@
37
37
  "@babel/parser": "^7.27.5",
38
38
  "@babel/traverse": "^7.27.4",
39
39
  "ai": "^5.0.29",
40
+ "ansi-to-html": "^0.7.2",
40
41
  "chalk": "^5.4.1",
41
42
  "commander": "^14.0.0",
42
43
  "debug": "^4.4.1",
@@ -10,13 +10,7 @@ export class GitHubActionsFormatter extends BaseFormatter {
10
10
  return `::group::${fullTaskName}`;
11
11
 
12
12
  case 'finish':
13
- const duration = extra.duration ? `${extra.duration}ms` : '';
14
- const extraInfo = Object.entries(extra)
15
- .filter(([k, v]) => v !== null && v !== undefined && k !== 'duration')
16
- .map(([k, v]) => `${k}: ${v}`)
17
- .join(', ');
18
- const details = [duration, extraInfo].filter(Boolean).join(', ');
19
- return `::endgroup::\n::notice::βœ… ${fullTaskName}${details ? ` (${details})` : ''}`;
13
+ return `::endgroup::`;
20
14
 
21
15
  case 'error':
22
16
  const errorDetails = extra.error ? ` - ${extra.error}` : '';
package/src/io.js CHANGED
@@ -30,6 +30,14 @@ export async function ask(question, defaultValueOrOptions = {}, options = {}) {
30
30
  opts = { ...defaultValueOrOptions, ...options };
31
31
  }
32
32
 
33
+ if (globalThis._bunoshUIMode && globalThis._bunoshUIServer) {
34
+ try {
35
+ return await globalThis._bunoshUIServer.askInUI(question, opts);
36
+ } finally {
37
+ globalThis._bunoshInAskOperation = false;
38
+ }
39
+ }
40
+
33
41
  // Route to appropriate handler based on options
34
42
  if (opts.editor || opts.multiline) {
35
43
  return await askWithEditor(question, opts);
package/src/program.js CHANGED
@@ -418,6 +418,19 @@ export default async function bunosh(commands, sources) {
418
418
 
419
419
  internalCommands.push(upgradeCmd);
420
420
 
421
+ const uiCmd = program.command('ui')
422
+ .description('Launch the Bunosh web UI server')
423
+ .option('-p, --port <port>', 'Port to listen on', '7171')
424
+ .option('-H, --host <host>', 'Host to bind to', '127.0.0.1')
425
+ .action(async (options) => {
426
+ const { default: BunoshWebServer } = await import('./server.js');
427
+ const server = new BunoshWebServer(parseInt(options.port), options.host);
428
+ await server.start();
429
+ console.log(color.dim('Press Ctrl+C to stop.'));
430
+ });
431
+
432
+ internalCommands.push(uiCmd);
433
+
421
434
 
422
435
  let helpText = '';
423
436
 
@@ -512,6 +525,7 @@ ${namespaceCommands}
512
525
 
513
526
  if (helpFlagRequested) {
514
527
  helpText += color.dim(`Special Commands:
528
+ ${color.bold('bunosh ui')} 🌐 Launch web UI server (default :7171)
515
529
  ${color.bold('bunosh edit')} πŸ“ Edit bunosh file with $EDITOR
516
530
  ${color.bold('bunosh export:scripts')} πŸ“₯ Export commands to package.json
517
531
  ${color.bold('bunosh upgrade')} 🦾 Upgrade bunosh
package/src/server.js ADDED
@@ -0,0 +1,1025 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+ import babelParser from "@babel/parser";
4
+ import traverseDefault from "@babel/traverse";
5
+ import Convert from 'ansi-to-html';
6
+
7
+ const traverse = traverseDefault.default || traverseDefault;
8
+
9
+ function camelToDasherize(camelCaseString) {
10
+ return camelCaseString.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
11
+ }
12
+
13
+ class BunoshWebServer {
14
+ constructor(port = 7171, host = '127.0.0.1') {
15
+ this.port = port;
16
+ this.host = host;
17
+ this.server = null;
18
+ this.clients = new Set();
19
+ this.commands = {};
20
+ this.bunoshSource = '';
21
+ this.pendingAskPromises = new Map();
22
+ this.ansiConverter = new Convert({
23
+ fg: '#e5e5e5',
24
+ bg: '#1a1a1a',
25
+ newline: false,
26
+ escapeXML: true,
27
+ stream: false
28
+ });
29
+ }
30
+
31
+ async start() {
32
+ await this.loadCommands();
33
+
34
+ this.server = Bun.serve({
35
+ port: this.port,
36
+ hostname: this.host,
37
+ fetch: (req, server) => this.handleRequest(req, server),
38
+ websocket: {
39
+ message: (ws, data) => this.handleWebSocketMessage(ws, data),
40
+ open: (ws) => this.clients.add(ws),
41
+ close: (ws) => this.clients.delete(ws),
42
+ },
43
+ });
44
+
45
+ console.log(`Bunosh UI server running at http://${this.host}:${this.port}`);
46
+ }
47
+
48
+ async loadCommands() {
49
+ const BUNOSHFILE = 'Bunoshfile.js';
50
+ const bunoshfilePath = path.join(process.cwd(), BUNOSHFILE);
51
+
52
+ if (!existsSync(bunoshfilePath)) {
53
+ throw new Error(`Bunoshfile not found: ${bunoshfilePath}`);
54
+ }
55
+
56
+ this.bunoshfilePath = bunoshfilePath;
57
+ this.bunoshSource = readFileSync(bunoshfilePath, 'utf-8');
58
+ const tasks = await import(bunoshfilePath);
59
+
60
+ this.commands = this.parseCommands(tasks, this.bunoshSource);
61
+ }
62
+
63
+ parseCommands(tasks, source) {
64
+ const commands = {};
65
+ let completeAst;
66
+
67
+ try {
68
+ completeAst = babelParser.parse(source, {
69
+ sourceType: "module",
70
+ ranges: true,
71
+ tokens: true,
72
+ comments: true,
73
+ attachComment: true,
74
+ });
75
+ } catch (parseError) {
76
+ throw new Error(`Failed to parse Bunoshfile: ${parseError.message}`);
77
+ }
78
+
79
+ const comments = this.fetchComments(completeAst, source);
80
+
81
+ Object.keys(tasks).forEach((fnName) => {
82
+ if (typeof tasks[fnName] !== 'function') return;
83
+
84
+ const fnBody = tasks[fnName].toString();
85
+ const ast = this.fetchFnAst(fnName, completeAst, fnBody);
86
+ const args = this.parseArgs(fnName, ast);
87
+ const opts = this.parseOpts(fnName, ast);
88
+ const comment = comments[fnName];
89
+ const commandName = this.prepareCommandName(fnName);
90
+
91
+ commands[commandName] = {
92
+ name: commandName,
93
+ originalName: fnName,
94
+ description: comment?.split('\n')[0] || '',
95
+ args,
96
+ opts,
97
+ code: this.fetchFnSource(fnName, completeAst, source),
98
+ fn: tasks[fnName]
99
+ };
100
+ });
101
+
102
+ return commands;
103
+ }
104
+
105
+ fetchComments(completeAst, source) {
106
+ const comments = {};
107
+ let startFromLine = 0;
108
+
109
+ traverse(completeAst, {
110
+ FunctionDeclaration(path) {
111
+ const functionName = path.node.id && path.node.id.name;
112
+
113
+ const commentSource = source
114
+ .split("\n")
115
+ .slice(startFromLine, path.node?.loc?.start?.line)
116
+ .join("\n");
117
+ const matches = commentSource.match(
118
+ /\/\*\*\s([\s\S]*)\\*\/\s*export/,
119
+ );
120
+
121
+ if (matches && matches[1]) {
122
+ comments[functionName] = matches[1]
123
+ .replace(/^\s*\*\s*/gm, "")
124
+ .replace(/\s*\*\*\s*$/gm, "")
125
+ .trim()
126
+ .replace(/^@.*$/gm, "")
127
+ .trim();
128
+ } else {
129
+ const firstStatement = path.node?.body?.body?.[0];
130
+ const leadingComments = firstStatement?.leadingComments;
131
+
132
+ if (leadingComments && leadingComments.length > 0) {
133
+ comments[functionName] = leadingComments[0].value.trim();
134
+ }
135
+ }
136
+
137
+ startFromLine = path.node?.loc?.end?.line;
138
+ },
139
+ });
140
+
141
+ return comments;
142
+ }
143
+
144
+ fetchFnAst(fnName, completeAst, fnBody) {
145
+ let hasFnInSource = false;
146
+
147
+ traverse(completeAst, {
148
+ FunctionDeclaration(path) {
149
+ if (path.node.id.name == fnName) {
150
+ hasFnInSource = true;
151
+ return;
152
+ }
153
+ },
154
+ });
155
+
156
+ if (hasFnInSource) return completeAst;
157
+ return babelParser.parse(fnBody, { comment: true, tokens: true });
158
+ }
159
+
160
+ fetchFnSource(fnName, completeAst, source) {
161
+ let code = '';
162
+ traverse(completeAst, {
163
+ FunctionDeclaration(path) {
164
+ if (path.node.id?.name !== fnName) return;
165
+ const target = path.parentPath && path.parentPath.isExportNamedDeclaration()
166
+ ? path.parentPath.node
167
+ : path.node;
168
+ let startOffset = target.start;
169
+ const lead = target.leadingComments;
170
+ if (lead && lead.length) startOffset = lead[0].start;
171
+ code = source.slice(startOffset, target.end).trim();
172
+ path.stop();
173
+ },
174
+ });
175
+ return code;
176
+ }
177
+
178
+ parseArgs(fnName, ast) {
179
+ const functionArguments = {};
180
+
181
+ traverse(ast, {
182
+ FunctionDeclaration(path) {
183
+ if (path.node.id.name !== fnName) return;
184
+
185
+ path.node.params
186
+ .filter((node) => {
187
+ return node?.right?.type !== "ObjectExpression";
188
+ })
189
+ .forEach((param) => {
190
+ if (param.type === "AssignmentPattern") {
191
+ functionArguments[param.left.name] = param.right.value;
192
+ return;
193
+ }
194
+ if (!param.name) return;
195
+ functionArguments[param.name] = null;
196
+ });
197
+ },
198
+ });
199
+
200
+ return functionArguments;
201
+ }
202
+
203
+ parseOpts(fnName, ast) {
204
+ let functionOpts = {};
205
+
206
+ traverse(ast, {
207
+ FunctionDeclaration(path) {
208
+ if (path.node.id.name !== fnName) return;
209
+
210
+ const node = path.node.params.pop();
211
+ if (!node) return;
212
+ if (
213
+ !node.type === "AssignmentPattern" &&
214
+ node.right.type === "ObjectExpression"
215
+ )
216
+ return;
217
+
218
+ node?.right?.properties?.forEach((p) => {
219
+ if (
220
+ ["NumericLiteral", "StringLiteral", "BooleanLiteral"].includes(
221
+ p.value.type,
222
+ )
223
+ ) {
224
+ functionOpts[camelToDasherize(p.key.name)] = p.value.value;
225
+ return;
226
+ }
227
+
228
+ if (p.value.type === "NullLiteral") {
229
+ functionOpts[camelToDasherize(p.key.name)] = null;
230
+ return;
231
+ }
232
+
233
+ if (p.value.type == "UnaryExpression" && p.value.operator == "!") {
234
+ functionOpts[camelToDasherize(p.key.name)] =
235
+ !p.value.argument.value;
236
+ return;
237
+ }
238
+ });
239
+ },
240
+ });
241
+
242
+ return functionOpts;
243
+ }
244
+
245
+ prepareCommandName(name) {
246
+ name = name
247
+ .split(/(?=[A-Z])/)
248
+ .join("-")
249
+ .toLowerCase();
250
+ return name.replace("-", ":");
251
+ }
252
+
253
+
254
+ async handleRequest(req, server) {
255
+ const url = new URL(req.url);
256
+
257
+ if (url.pathname === '/ws') {
258
+ if (server.upgrade(req)) {
259
+ return;
260
+ }
261
+ return new Response('Upgrade failed', { status: 500 });
262
+ }
263
+
264
+ if (req.method === 'GET' && url.pathname === '/') {
265
+ return new Response(this.getIndexHTML(), {
266
+ headers: { 'Content-Type': 'text/html' }
267
+ });
268
+ } else if (req.method === 'GET' && url.pathname === '/api/commands') {
269
+ return new Response(JSON.stringify(this.commands), {
270
+ headers: { 'Content-Type': 'application/json' }
271
+ });
272
+ } else if (req.method === 'GET' && url.pathname === '/api/source') {
273
+ return new Response(JSON.stringify({ path: this.bunoshfilePath, source: this.bunoshSource }), {
274
+ headers: { 'Content-Type': 'application/json' }
275
+ });
276
+ } else if (req.method === 'POST' && url.pathname === '/api/execute') {
277
+ return await this.handleExecuteCommand(req);
278
+ } else {
279
+ return new Response('Not Found', { status: 404 });
280
+ }
281
+ }
282
+
283
+ async handleExecuteCommand(req) {
284
+ try {
285
+ const body = await req.text();
286
+ const { command, args, opts } = JSON.parse(body);
287
+
288
+ if (!this.commands[command]) {
289
+ return new Response(JSON.stringify({ error: 'Command not found' }), {
290
+ status: 400,
291
+ headers: { 'Content-Type': 'application/json' }
292
+ });
293
+ }
294
+
295
+ const executionId = Date.now().toString();
296
+
297
+ this.executeCommand(command, args, opts, executionId);
298
+
299
+ return new Response(JSON.stringify({ executionId, status: 'started' }), {
300
+ headers: { 'Content-Type': 'application/json' }
301
+ });
302
+ } catch (error) {
303
+ return new Response(JSON.stringify({ error: error.message }), {
304
+ status: 400,
305
+ headers: { 'Content-Type': 'application/json' }
306
+ });
307
+ }
308
+ }
309
+
310
+ async executeCommand(commandName, args, opts, executionId) {
311
+ const command = this.commands[commandName];
312
+
313
+ this.broadcast({
314
+ type: 'execution_start',
315
+ executionId,
316
+ command: commandName
317
+ });
318
+
319
+ const originalIsTTY = process.stdout.isTTY;
320
+
321
+ try {
322
+ await import('../index.js');
323
+
324
+ globalThis._bunoshUIMode = true;
325
+ globalThis._bunoshUIExecutionId = executionId;
326
+ globalThis._bunoshUIServer = this;
327
+
328
+ process.env.FORCE_COLOR = '1';
329
+ process.env.TERM = 'xterm-256color';
330
+
331
+ process.stdout.isTTY = true;
332
+ process.stderr.isTTY = true;
333
+
334
+ const originalConsoleLog = console.log;
335
+ const originalConsoleError = console.error;
336
+
337
+ console.log = (...args) => {
338
+ const rawMessage = args.join(' ');
339
+ const htmlMessage = this.ansiConverter.toHtml(rawMessage);
340
+ this.broadcast({
341
+ type: 'output',
342
+ executionId,
343
+ level: 'info',
344
+ message: rawMessage,
345
+ html: htmlMessage
346
+ });
347
+ originalConsoleLog(...args);
348
+ };
349
+
350
+ console.error = (...args) => {
351
+ const rawMessage = args.join(' ');
352
+ const htmlMessage = this.ansiConverter.toHtml(rawMessage);
353
+ this.broadcast({
354
+ type: 'output',
355
+ executionId,
356
+ level: 'error',
357
+ message: rawMessage,
358
+ html: htmlMessage
359
+ });
360
+ originalConsoleError(...args);
361
+ };
362
+
363
+ const result = await command.fn(...Object.values(args), opts);
364
+
365
+ console.log = originalConsoleLog;
366
+ console.error = originalConsoleError;
367
+
368
+ process.stdout.isTTY = originalIsTTY;
369
+ process.stderr.isTTY = originalIsTTY;
370
+
371
+ globalThis._bunoshUIMode = false;
372
+ globalThis._bunoshUIExecutionId = null;
373
+ globalThis._bunoshUIServer = null;
374
+
375
+ this.broadcast({
376
+ type: 'execution_complete',
377
+ executionId,
378
+ status: 'success',
379
+ result
380
+ });
381
+ } catch (error) {
382
+ process.stdout.isTTY = originalIsTTY;
383
+ process.stderr.isTTY = originalIsTTY;
384
+
385
+ globalThis._bunoshUIMode = false;
386
+ globalThis._bunoshUIExecutionId = null;
387
+ globalThis._bunoshUIServer = null;
388
+
389
+ this.broadcast({
390
+ type: 'execution_complete',
391
+ executionId,
392
+ status: 'error',
393
+ error: error.message
394
+ });
395
+ }
396
+ }
397
+
398
+ handleWebSocketMessage(ws, data) {
399
+ try {
400
+ const message = JSON.parse(data.toString());
401
+
402
+ if (message.type === 'ask_response' && this.pendingAskPromises.has(message.askId)) {
403
+ const resolve = this.pendingAskPromises.get(message.askId);
404
+ this.pendingAskPromises.delete(message.askId);
405
+ resolve(message.value);
406
+ }
407
+ } catch (error) {
408
+ console.error('WebSocket message error:', error);
409
+ }
410
+ }
411
+
412
+ broadcast(message) {
413
+ this.clients.forEach(client => {
414
+ client.send(JSON.stringify(message));
415
+ });
416
+ }
417
+
418
+ async askInUI(question, opts = {}) {
419
+ const askId = Date.now().toString() + Math.random();
420
+ const executionId = globalThis._bunoshUIExecutionId;
421
+
422
+ return new Promise((resolve) => {
423
+ this.pendingAskPromises.set(askId, resolve);
424
+
425
+ this.broadcast({
426
+ type: 'ask_prompt',
427
+ executionId,
428
+ askId,
429
+ question,
430
+ opts
431
+ });
432
+ });
433
+ }
434
+
435
+ getIndexHTML() {
436
+ let version = '';
437
+ try {
438
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
439
+ version = pkg.version;
440
+ } catch (e) {}
441
+
442
+ const cwd = process.cwd();
443
+ const home = process.env.HOME || '';
444
+ const cwdDisplay = (home && cwd.startsWith(home)) ? '~' + cwd.slice(home.length) : cwd;
445
+ const bunoshfileName = this.bunoshfilePath ? path.basename(this.bunoshfilePath) : 'Bunoshfile.js';
446
+ const meta = JSON.stringify({ cwd, cwdDisplay, file: bunoshfileName });
447
+
448
+ return `<!DOCTYPE html>
449
+ <html lang="en">
450
+ <head>
451
+ <meta charset="UTF-8">
452
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
453
+ <title>Bunosh</title>
454
+ <link rel="preconnect" href="https://fonts.googleapis.com">
455
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
456
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
457
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
458
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
459
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
460
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
461
+ <style>
462
+ :root {
463
+ --bg: #100d09;
464
+ --surface: #1b1610;
465
+ --surface-2: #221c15;
466
+ --border: #2e2720;
467
+ --border-strong: #3b322a;
468
+ --text: #ece3d6;
469
+ --muted: #a99e8d;
470
+ --faint: #6f6557;
471
+ --accent: #f0a02e;
472
+ --accent-soft: rgba(240,160,46,.12);
473
+ --accent-line: rgba(240,160,46,.45);
474
+ --success: #8fb368;
475
+ --danger: #db5b39;
476
+ --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
477
+ --sans: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif;
478
+ }
479
+ * { box-sizing: border-box; }
480
+ html, body, #root { height: 100%; margin: 0; }
481
+ body {
482
+ background: var(--bg);
483
+ color: var(--text);
484
+ font-family: var(--mono);
485
+ font-size: 14px;
486
+ -webkit-font-smoothing: antialiased;
487
+ }
488
+ body::before {
489
+ content: "";
490
+ position: fixed;
491
+ inset: 0;
492
+ pointer-events: none;
493
+ z-index: 0;
494
+ background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><circle cx='1.5' cy='1.5' r='1' fill='%23f0a02e' fill-opacity='0.05'/></svg>");
495
+ background-size: 24px 24px;
496
+ }
497
+ #root { position: relative; z-index: 1; }
498
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
499
+ ::-webkit-scrollbar-thumb { background: #332b22; border-radius: 6px; }
500
+ ::-webkit-scrollbar-thumb:hover { background: #463b2e; }
501
+ ::-webkit-scrollbar-track { background: transparent; }
502
+
503
+ @keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
504
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
505
+ .reveal { opacity: 0; animation: fadeUp .5s cubic-bezier(.2,.7,.2,1) forwards; }
506
+ @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; animation: none; } }
507
+
508
+ .app { height: 100%; display: flex; flex-direction: column; border-top: 3px solid var(--accent); }
509
+ .topbar { display: flex; align-items: center; justify-content: space-between; height: 60px; padding: 0 22px; border-bottom: 1px solid var(--border); background: var(--surface); }
510
+ .brand { display: flex; align-items: center; gap: 10px; }
511
+ .brand-mark { font-size: 24px; line-height: 1; }
512
+ .brand-name { font-family: var(--sans); font-weight: 700; font-size: 23px; letter-spacing: -.02em; }
513
+ .brand-ver { font-family: var(--mono); font-size: 11px; color: var(--accent); border: 1px solid var(--accent-line); border-radius: 999px; padding: 2px 8px; }
514
+ .status { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
515
+ .status .addr { color: var(--faint); text-transform: none; letter-spacing: 0; }
516
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); display: inline-block; flex-shrink: 0; }
517
+ .dot--running { background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
518
+ .dot--success { background: var(--success); }
519
+ .dot--error, .dot--offline { background: var(--danger); }
520
+
521
+ .body { flex: 1; display: flex; min-height: 0; }
522
+ .sidebar { width: 282px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: #161109; }
523
+ .search { padding: 14px; border-bottom: 1px solid var(--border); }
524
+ .search-box { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--bg); transition: border-color .15s; }
525
+ .search-box:focus-within { border-color: var(--accent-line); }
526
+ .search-box input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13px; caret-color: var(--accent); }
527
+ .search-box input::placeholder { color: var(--faint); }
528
+ .search-box kbd { font-family: var(--mono); font-size: 10px; color: var(--faint); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 5px; }
529
+ .cmd-list { flex: 1; overflow-y: auto; padding: 8px; }
530
+ .cmd { display: block; width: 100%; text-align: left; padding: 9px 12px; margin-bottom: 2px; border: none; border-left: 2px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; transition: background .14s, border-color .14s; }
531
+ .cmd:hover { background: var(--accent-soft); }
532
+ .cmd.is-active { background: var(--accent-soft); border-left-color: var(--accent); }
533
+ .cmd-name { font-family: var(--mono); font-size: 13px; color: var(--text); }
534
+ .cmd.is-active .cmd-name { color: var(--accent); }
535
+ .cmd-desc { font-size: 11px; color: var(--faint); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
536
+ .sidebar-foot { padding: 10px 16px; border-top: 1px solid var(--border); font-size: 11px; color: var(--faint); letter-spacing: .04em; }
537
+ .no-match { padding: 28px 12px; text-align: center; color: var(--faint); font-size: 13px; }
538
+
539
+ .main { flex: 1; min-width: 0; overflow-y: auto; }
540
+ .detail { max-width: 760px; margin: 0 auto; padding: 34px 28px; }
541
+ .cmd-title { font-family: var(--mono); font-size: 24px; font-weight: 500; color: var(--text); letter-spacing: -.01em; margin: 0; }
542
+ .cmd-sub { font-family: var(--sans); font-size: 16px; color: var(--muted); margin: 7px 0 0; }
543
+ .panel { margin-top: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); padding: 20px; box-shadow: 0 18px 40px -28px rgba(0,0,0,.85); }
544
+ .field + .field { margin-top: 16px; }
545
+ .field-label { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; font-family: var(--mono); font-size: 13px; color: var(--accent); }
546
+ .badge { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; padding: 2px 6px; border-radius: 5px; }
547
+ .badge--req { color: var(--danger); background: rgba(219,91,57,.14); }
548
+ .badge--opt { color: var(--muted); background: rgba(169,158,141,.14); }
549
+ .input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-strong); border-radius: 9px; background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; outline: none; transition: border-color .15s; }
550
+ .input:focus { border-color: var(--accent-line); }
551
+ .input::placeholder { color: var(--faint); }
552
+ .check { display: flex; align-items: center; gap: 9px; cursor: pointer; user-select: none; font-family: var(--mono); font-size: 13px; color: var(--accent); }
553
+ .check input { width: 16px; height: 16px; accent-color: var(--accent); }
554
+ .no-args { color: var(--faint); font-size: 13px; margin-top: 20px; }
555
+ .actions { display: flex; align-items: center; gap: 12px; margin-top: 20px; }
556
+ .btn { font-family: var(--mono); font-weight: 500; font-size: 13px; letter-spacing: .02em; padding: 11px 18px; border-radius: 10px; border: 1px solid transparent; cursor: pointer; transition: transform .12s, background .15s, color .15s, border-color .15s; }
557
+ .btn--primary { background: var(--accent); color: #1a1206; box-shadow: 0 1px 0 rgba(255,255,255,.18) inset, 0 12px 26px -14px rgba(240,160,46,.7); }
558
+ .btn--primary:hover { background: #ffb648; transform: translateY(-1px); }
559
+ .btn--primary:active { transform: translateY(0); }
560
+ .btn--primary:disabled { background: var(--surface-2); color: var(--faint); box-shadow: none; cursor: not-allowed; transform: none; }
561
+ .btn--ghost { background: transparent; color: var(--muted); border-color: var(--border-strong); }
562
+ .btn--ghost:hover { color: var(--text); background: var(--accent-soft); border-color: var(--accent-line); }
563
+
564
+ .output { margin-top: 24px; border: 1px solid var(--border); border-radius: 14px; overflow: hidden; background: #0b0a07; }
565
+ .output-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 16px; background: var(--surface); border-bottom: 1px solid var(--border); }
566
+ .output-head .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
567
+ .output-status { display: flex; align-items: center; gap: 7px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
568
+ .output-body { padding: 16px; font-family: var(--mono); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; overflow-y: auto; max-height: 56vh; color: #e7ddd0; }
569
+
570
+ .empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px; }
571
+ .empty-mark { font-size: 54px; margin-bottom: 18px; }
572
+ .empty h2 { font-family: var(--sans); font-weight: 600; font-size: 26px; color: var(--text); margin: 0; letter-spacing: -.01em; }
573
+ .empty p { font-family: var(--sans); color: var(--muted); max-width: 390px; margin: 11px 0 0; font-size: 15px; line-height: 1.55; }
574
+ .empty .hint { margin-top: 20px; font-size: 12px; color: var(--faint); }
575
+ .empty kbd { font-family: var(--mono); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px; color: var(--muted); }
576
+
577
+ .modal-backdrop { position: fixed; inset: 0; background: rgba(8,6,3,.7); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 16px; }
578
+ .modal { width: 100%; max-width: 440px; border: 1px solid var(--border-strong); border-radius: 18px; background: var(--surface); padding: 24px; box-shadow: 0 30px 60px -20px rgba(0,0,0,.85); }
579
+ .modal-title { font-family: var(--sans); font-weight: 600; font-size: 19px; color: var(--text); margin: 0 0 18px; }
580
+ .modal-row { display: flex; gap: 12px; }
581
+ .modal-row .btn { flex: 1; }
582
+ .choices { display: flex; flex-direction: column; gap: 8px; }
583
+ .choice { font-family: var(--mono); font-size: 13px; text-align: left; padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--bg); color: var(--text); cursor: pointer; transition: border-color .15s, background .15s; }
584
+ .choice:hover { border-color: var(--accent-line); background: var(--accent-soft); }
585
+ .modal .input { margin-bottom: 16px; }
586
+
587
+ .meta { display: flex; align-items: center; gap: 14px; min-width: 0; font-size: 12px; color: var(--muted); }
588
+ .meta-cwd { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 40ch; }
589
+ .meta-file { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-line); border-radius: 999px; padding: 3px 11px; cursor: pointer; transition: background .15s; white-space: nowrap; flex-shrink: 0; }
590
+ .meta-file:hover { background: rgba(240,160,46,.2); }
591
+
592
+ .detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
593
+ .detail-head-text { min-width: 0; }
594
+ .btn--sm { padding: 7px 13px; font-size: 12px; }
595
+ .code-toggle { flex-shrink: 0; }
596
+ .code-block { margin-top: 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 12px; background: #0b0a07; color: #e7ddd0; font-family: var(--mono); font-size: 12.5px; line-height: 1.6; overflow: auto; max-height: 440px; white-space: pre; tab-size: 2; }
597
+
598
+ .modal-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
599
+ .modal-close { background: transparent; border: none; color: var(--muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 4px 6px; border-radius: 6px; }
600
+ .modal-close:hover { color: var(--text); background: var(--accent-soft); }
601
+ .modal--code { max-width: 820px; }
602
+ .modal--code .code-block { margin-top: 0; max-height: 70vh; }
603
+
604
+ .code-block code.hljs { background: transparent; padding: 0; color: #e7ddd0; }
605
+ .hljs-comment, .hljs-quote { color: #6f6557; font-style: italic; }
606
+ .hljs-keyword, .hljs-literal, .hljs-selector-tag, .hljs-section, .hljs-doctag, .hljs-name { color: #f0a02e; }
607
+ .hljs-title, .hljs-title.function_, .hljs-title.class_ { color: #f3cf8c; }
608
+ .hljs-string, .hljs-regexp, .hljs-meta .hljs-string, .hljs-addition { color: #9ec27a; }
609
+ .hljs-number, .hljs-symbol, .hljs-bullet { color: #e08a5a; }
610
+ .hljs-built_in, .hljs-type, .hljs-builtin-name { color: #d8a85e; }
611
+ .hljs-attr, .hljs-attribute, .hljs-property, .hljs-variable, .hljs-template-variable, .hljs-params { color: #cdbfa6; }
612
+ .hljs-operator, .hljs-punctuation { color: #a99e8d; }
613
+ .hljs-emphasis { font-style: italic; }
614
+ .hljs-strong { font-weight: 700; }
615
+
616
+ .shortcuts { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 8px 22px; border-top: 1px solid var(--border); background: var(--surface); font-size: 11px; color: var(--faint); flex-shrink: 0; }
617
+ .shortcuts .sc { display: flex; align-items: center; gap: 5px; }
618
+ .shortcuts kbd { font-family: var(--mono); font-size: 10px; color: var(--muted); background: var(--bg); border: 1px solid var(--border-strong); border-radius: 5px; padding: 1px 6px; }
619
+ </style>
620
+ </head>
621
+ <body>
622
+ <div id="root"></div>
623
+ <script type="text/babel" data-type="module">
624
+ const { useState, useEffect, useRef } = React;
625
+
626
+ const META = ${meta};
627
+
628
+ function CodeBlock({ code }) {
629
+ const hl = (window.hljs && code) ? window.hljs.highlight(code, { language: "javascript" }).value : null;
630
+ if (hl) {
631
+ return <pre className="code-block"><code className="hljs" dangerouslySetInnerHTML={{ __html: hl }} /></pre>;
632
+ }
633
+ return <pre className="code-block"><code className="hljs">{code}</code></pre>;
634
+ }
635
+
636
+ function AskModal({ prompt, onAnswer }) {
637
+ const [value, setValue] = useState(prompt.opts.default || "");
638
+ const isConfirm = prompt.opts.type === "confirm";
639
+ const choices = prompt.opts.choices;
640
+ const submit = (e) => { e.preventDefault(); onAnswer(value); };
641
+
642
+ return (
643
+ <div className="modal-backdrop">
644
+ <div className="modal">
645
+ <h3 className="modal-title">{prompt.question}</h3>
646
+ {isConfirm ? (
647
+ <div className="modal-row">
648
+ <button className="btn btn--primary" onClick={() => onAnswer(true)}>Yes</button>
649
+ <button className="btn btn--ghost" onClick={() => onAnswer(false)}>No</button>
650
+ </div>
651
+ ) : choices ? (
652
+ <div className="choices">
653
+ {choices.map((ch, i) => (
654
+ <button key={i} className="choice" onClick={() => onAnswer(ch)}>{ch}</button>
655
+ ))}
656
+ </div>
657
+ ) : (
658
+ <form onSubmit={submit}>
659
+ <input
660
+ className="input"
661
+ autoFocus
662
+ value={value}
663
+ onChange={(e) => setValue(e.target.value)}
664
+ placeholder={prompt.opts.default || "Type your answer…"}
665
+ />
666
+ <div className="modal-row">
667
+ <button type="submit" className="btn btn--primary">Submit</button>
668
+ <button type="button" className="btn btn--ghost" onClick={() => onAnswer(prompt.opts.default || "")}>Cancel</button>
669
+ </div>
670
+ </form>
671
+ )}
672
+ </div>
673
+ </div>
674
+ );
675
+ }
676
+
677
+ function App() {
678
+ const [commands, setCommands] = useState({});
679
+ const [query, setQuery] = useState("");
680
+ const [selected, setSelected] = useState(null);
681
+ const [formData, setFormData] = useState({});
682
+ const [output, setOutput] = useState("");
683
+ const [isExecuting, setIsExecuting] = useState(false);
684
+ const [runStatus, setRunStatus] = useState("idle");
685
+ const [connected, setConnected] = useState(false);
686
+ const [askPrompt, setAskPrompt] = useState(null);
687
+ const [showCode, setShowCode] = useState(false);
688
+ const [fileOpen, setFileOpen] = useState(false);
689
+ const [fileSource, setFileSource] = useState("");
690
+ const wsRef = useRef(null);
691
+ const outRef = useRef(null);
692
+ const searchRef = useRef(null);
693
+ const listRef = useRef(null);
694
+ const mainRef = useRef(null);
695
+ const pendingFocusRef = useRef(false);
696
+
697
+ function onMessage(m) {
698
+ if (m.type === "output") {
699
+ setOutput((prev) => prev + (m.html || m.message) + "\\n");
700
+ } else if (m.type === "execution_start") {
701
+ setIsExecuting(true);
702
+ setRunStatus("running");
703
+ setOutput("");
704
+ } else if (m.type === "execution_complete") {
705
+ setIsExecuting(false);
706
+ setRunStatus(m.status === "error" ? "error" : "success");
707
+ setAskPrompt(null);
708
+ } else if (m.type === "ask_prompt") {
709
+ setAskPrompt(m);
710
+ }
711
+ }
712
+
713
+ useEffect(() => {
714
+ fetch("/api/commands").then((r) => r.json()).then(setCommands);
715
+ let socket, timer, closed = false;
716
+ const connect = () => {
717
+ socket = new WebSocket("ws://" + window.location.host + "/ws");
718
+ socket.onopen = () => setConnected(true);
719
+ socket.onclose = () => { setConnected(false); if (!closed) timer = setTimeout(connect, 1500); };
720
+ socket.onmessage = (e) => onMessage(JSON.parse(e.data));
721
+ wsRef.current = socket;
722
+ };
723
+ connect();
724
+ return () => { closed = true; clearTimeout(timer); if (socket) socket.close(); };
725
+ }, []);
726
+
727
+ useEffect(() => {
728
+ if (!selected || !listRef.current) return;
729
+ const active = listRef.current.querySelector(".cmd.is-active");
730
+ if (active) {
731
+ active.scrollIntoView({ block: "nearest" });
732
+ if (pendingFocusRef.current) { active.focus(); pendingFocusRef.current = false; }
733
+ }
734
+ }, [selected]);
735
+
736
+ useEffect(() => {
737
+ if (outRef.current) outRef.current.scrollTop = outRef.current.scrollHeight;
738
+ }, [output]);
739
+
740
+ const q = query.trim().toLowerCase();
741
+ const list = Object.values(commands).filter((c) => {
742
+ if (!q) return true;
743
+ return c.name.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q);
744
+ });
745
+ const total = Object.keys(commands).length;
746
+
747
+ const select = (c) => { setSelected(c); setFormData({}); setOutput(""); setRunStatus("idle"); setShowCode(false); };
748
+
749
+ const run = (e) => {
750
+ if (e) e.preventDefault();
751
+ if (!selected || isExecuting) return;
752
+ const args = {};
753
+ const opts = {};
754
+ Object.keys(selected.args).forEach((k) => {
755
+ args[k] = (formData[k] !== undefined && formData[k] !== "") ? formData[k] : selected.args[k];
756
+ });
757
+ Object.keys(selected.opts).forEach((k) => {
758
+ opts[k] = formData[k] !== undefined ? formData[k] : selected.opts[k];
759
+ });
760
+ fetch("/api/execute", {
761
+ method: "POST",
762
+ headers: { "Content-Type": "application/json" },
763
+ body: JSON.stringify({ command: selected.name, args, opts })
764
+ }).catch((err) => setOutput((prev) => prev + "Error: " + err.message + "\\n"));
765
+ };
766
+
767
+ const answer = (value) => {
768
+ if (askPrompt && wsRef.current) {
769
+ wsRef.current.send(JSON.stringify({ type: "ask_response", askId: askPrompt.askId, value }));
770
+ setAskPrompt(null);
771
+ }
772
+ };
773
+
774
+ const clear = () => { setOutput(""); setRunStatus("idle"); };
775
+
776
+ const openFile = () => {
777
+ setFileOpen(true);
778
+ if (!fileSource) {
779
+ fetch("/api/source").then((r) => r.json()).then((d) => setFileSource(d.source || ""));
780
+ }
781
+ };
782
+
783
+ const hasParams = selected && (Object.keys(selected.args).length > 0 || Object.keys(selected.opts).length > 0);
784
+ const headStatus = connected ? (runStatus === "idle" ? "ready" : runStatus) : "offline";
785
+
786
+ useEffect(() => {
787
+ const focusMain = () => {
788
+ const main = mainRef.current;
789
+ const target = main && (main.querySelector(".input") || main.querySelector(".check input") || main.querySelector(".btn--primary"));
790
+ if (target) target.focus();
791
+ };
792
+ const focusList = () => {
793
+ const listEl = listRef.current;
794
+ const target = listEl && (listEl.querySelector(".cmd.is-active") || listEl.querySelector(".cmd"));
795
+ if (target) target.focus();
796
+ };
797
+ const onKey = (e) => {
798
+ if (e.key === "Escape" && fileOpen) { setFileOpen(false); return; }
799
+
800
+ const el = document.activeElement;
801
+ const inField = el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA");
802
+ const inOtherField = inField && el !== searchRef.current;
803
+ const noMod = !e.ctrlKey && !e.metaKey && !e.altKey;
804
+
805
+ if (e.key === "/" && noMod) {
806
+ if (inOtherField) return;
807
+ e.preventDefault();
808
+ if (searchRef.current) { searchRef.current.focus(); searchRef.current.select(); }
809
+ return;
810
+ }
811
+
812
+ if (e.key === "ArrowRight" && (e.ctrlKey || (noMod && !inField))) {
813
+ e.preventDefault();
814
+ focusMain();
815
+ return;
816
+ }
817
+
818
+ if (e.key === "ArrowLeft" && (e.ctrlKey || (noMod && !inField))) {
819
+ e.preventDefault();
820
+ focusList();
821
+ return;
822
+ }
823
+
824
+ if ((e.key === "ArrowDown" || e.key === "ArrowUp") && noMod) {
825
+ if (askPrompt || list.length === 0) return;
826
+ if (inOtherField) return;
827
+ if (mainRef.current && mainRef.current.contains(el)) return;
828
+ e.preventDefault();
829
+ const idx = selected ? list.findIndex((c) => c.name === selected.name) : -1;
830
+ let next = e.key === "ArrowDown" ? idx + 1 : idx - 1;
831
+ if (next < 0) next = list.length - 1;
832
+ if (next >= list.length) next = 0;
833
+ pendingFocusRef.current = !!(el && el.classList && el.classList.contains("cmd"));
834
+ select(list[next]);
835
+ }
836
+ };
837
+ window.addEventListener("keydown", onKey);
838
+ return () => window.removeEventListener("keydown", onKey);
839
+ }, [list, selected, askPrompt, fileOpen]);
840
+
841
+ return (
842
+ <div className="app">
843
+ <header className="topbar reveal">
844
+ <div className="brand">
845
+ <span className="brand-mark">🍲</span>
846
+ <span className="brand-name">Bunosh</span>
847
+ <span className="brand-ver">v${version}</span>
848
+ </div>
849
+ <div className="meta">
850
+ <span className="meta-cwd" title={META.cwd}>πŸ“ {META.cwdDisplay}</span>
851
+ <button className="meta-file" onClick={openFile} title="View Bunoshfile source">πŸ“„ {META.file}</button>
852
+ </div>
853
+ <div className="status">
854
+ <span className={"dot dot--" + (connected ? runStatus : "offline")}></span>
855
+ <span>{headStatus}</span>
856
+ <span className="addr">{window.location.host}</span>
857
+ </div>
858
+ </header>
859
+
860
+ <div className="body">
861
+ <aside className="sidebar reveal" style={{ animationDelay: "60ms" }}>
862
+ <div className="search">
863
+ <div className="search-box">
864
+ <span>πŸ”</span>
865
+ <input
866
+ ref={searchRef}
867
+ value={query}
868
+ onChange={(e) => setQuery(e.target.value)}
869
+ placeholder="Search commands…"
870
+ autoFocus
871
+ />
872
+ <kbd>/</kbd>
873
+ </div>
874
+ </div>
875
+ <div className="cmd-list" ref={listRef}>
876
+ {list.map((c, i) => (
877
+ <button
878
+ key={c.name}
879
+ className={"cmd reveal" + (selected && selected.name === c.name ? " is-active" : "")}
880
+ style={{ animationDelay: (Math.min(i, 18) * 22) + "ms" }}
881
+ onClick={() => select(c)}
882
+ >
883
+ <div className="cmd-name">{c.name}</div>
884
+ {c.description ? <div className="cmd-desc">{c.description}</div> : null}
885
+ </button>
886
+ ))}
887
+ {list.length === 0 ? <div className="no-match">No commands match β€œ{query}”.</div> : null}
888
+ </div>
889
+ <div className="sidebar-foot">
890
+ {list.length === total ? total + " commands" : list.length + " / " + total + " commands"}
891
+ </div>
892
+ </aside>
893
+
894
+ <main className="main" ref={mainRef}>
895
+ {selected ? (
896
+ <div className="detail reveal" key={selected.name}>
897
+ <div className="detail-head">
898
+ <div className="detail-head-text">
899
+ <h1 className="cmd-title">{selected.name}</h1>
900
+ {selected.description ? <p className="cmd-sub">{selected.description}</p> : null}
901
+ </div>
902
+ {selected.code ? (
903
+ <button type="button" className="btn btn--ghost btn--sm code-toggle" onClick={() => setShowCode((s) => !s)}>
904
+ {showCode ? "Hide code" : "Show code"}
905
+ </button>
906
+ ) : null}
907
+ </div>
908
+
909
+ {showCode && selected.code ? <CodeBlock code={selected.code} /> : null}
910
+
911
+ <form onSubmit={run}>
912
+ {hasParams ? (
913
+ <div className="panel">
914
+ {Object.entries(selected.args).map(([key, def]) => (
915
+ <div className="field" key={key}>
916
+ <label className="field-label">
917
+ <span>{key}</span>
918
+ <span className={"badge " + (def === null ? "badge--req" : "badge--opt")}>{def === null ? "required" : "optional"}</span>
919
+ </label>
920
+ <input
921
+ className="input"
922
+ type="text"
923
+ placeholder={(def !== null && def !== undefined) ? String(def) : "Enter " + key}
924
+ value={formData[key] || ""}
925
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
926
+ />
927
+ </div>
928
+ ))}
929
+ {Object.entries(selected.opts).map(([key, def]) => (
930
+ <div className="field" key={key}>
931
+ {typeof def === "boolean" ? (
932
+ <label className="check">
933
+ <input
934
+ type="checkbox"
935
+ checked={formData[key] !== undefined ? formData[key] : def}
936
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.checked })}
937
+ />
938
+ <span>--{key}</span>
939
+ </label>
940
+ ) : (
941
+ <div>
942
+ <label className="field-label"><span>--{key}</span></label>
943
+ <input
944
+ className="input"
945
+ type="text"
946
+ placeholder={(def !== null && def !== undefined) ? String(def) : "Enter " + key}
947
+ value={formData[key] || ""}
948
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
949
+ />
950
+ </div>
951
+ )}
952
+ </div>
953
+ ))}
954
+ </div>
955
+ ) : (
956
+ <p className="no-args">This command takes no arguments.</p>
957
+ )}
958
+
959
+ <div className="actions">
960
+ <button type="submit" className="btn btn--primary" disabled={isExecuting}>
961
+ {isExecuting ? "Running…" : "β–Ά Launch"}
962
+ </button>
963
+ {output ? <button type="button" className="btn btn--ghost" onClick={clear}>Clear</button> : null}
964
+ </div>
965
+ </form>
966
+
967
+ {(output || isExecuting) ? (
968
+ <div className="output">
969
+ <div className="output-head">
970
+ <span className="lbl">Output</span>
971
+ <span className="output-status">
972
+ <span className={"dot dot--" + runStatus}></span>
973
+ {runStatus}
974
+ </span>
975
+ </div>
976
+ <div className="output-body" ref={outRef} dangerouslySetInnerHTML={{ __html: output || "" }} />
977
+ </div>
978
+ ) : null}
979
+ </div>
980
+ ) : (
981
+ <div className="empty">
982
+ <div className="empty-mark">🍲</div>
983
+ <h2>Pick a command to run</h2>
984
+ <p>Search and select a task, fill in its arguments, and hit Launch. Output streams right below.</p>
985
+ <div className="hint">Press <kbd>/</kbd> to search</div>
986
+ </div>
987
+ )}
988
+ </main>
989
+ </div>
990
+
991
+ <footer className="shortcuts">
992
+ <span className="sc"><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
993
+ <span className="sc"><kbd>β†’</kbd> open</span>
994
+ <span className="sc"><kbd>←</kbd> back</span>
995
+ <span className="sc"><kbd>Ctrl</kbd>+<kbd>←</kbd>/<kbd>β†’</kbd> jump panes</span>
996
+ <span className="sc"><kbd>/</kbd> search</span>
997
+ <span className="sc"><kbd>Enter</kbd> run</span>
998
+ <span className="sc"><kbd>Esc</kbd> close</span>
999
+ </footer>
1000
+
1001
+ {askPrompt ? <AskModal prompt={askPrompt} onAnswer={answer} /> : null}
1002
+
1003
+ {fileOpen ? (
1004
+ <div className="modal-backdrop" onClick={() => setFileOpen(false)}>
1005
+ <div className="modal modal--code" onClick={(e) => e.stopPropagation()}>
1006
+ <div className="modal-head">
1007
+ <h3 className="modal-title">πŸ“„ {META.file}</h3>
1008
+ <button className="modal-close" onClick={() => setFileOpen(false)} aria-label="Close">βœ•</button>
1009
+ </div>
1010
+ {fileSource ? <CodeBlock code={fileSource} /> : <pre className="code-block"><code className="hljs">Loading…</code></pre>}
1011
+ </div>
1012
+ </div>
1013
+ ) : null}
1014
+ </div>
1015
+ );
1016
+ }
1017
+
1018
+ ReactDOM.render(<App />, document.getElementById("root"));
1019
+ </script>
1020
+ </body>
1021
+ </html>`;
1022
+ }
1023
+ }
1024
+
1025
+ export default BunoshWebServer;