@bhooai/nexus-cli 2.0.3 → 2.0.5

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +1 -1
  3. package/src/commands/dev.ts +3 -3
  4. package/src/commands/init.ts +87 -136
  5. package/src/devPanel.ts +602 -346
  6. package/src/devServiceManager.ts +50 -4
  7. package/src/dispatcher.ts +7 -1
  8. package/src/examples.ts +90 -0
  9. package/src/features.ts +261 -0
  10. package/src/launcher.ts +164 -0
  11. package/src/layout.ts +101 -0
  12. package/src/templating/tree.ts +66 -0
  13. package/src/tui.ts +170 -0
  14. package/src/wizard.ts +691 -0
  15. package/templates/base/Dockerfile.ejs +1 -0
  16. package/templates/base/apps/admin/nginx.conf.ejs +30 -1
  17. package/templates/base/apps/admin/package.json.ejs +7 -2
  18. package/templates/base/apps/admin/postcss.config.js +5 -0
  19. package/templates/base/apps/admin/src/App.tsx +4127 -0
  20. package/templates/base/apps/admin/src/alertCenter.tsx +150 -0
  21. package/templates/base/apps/admin/src/api.ts +474 -0
  22. package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +25 -0
  23. package/templates/base/apps/admin/src/index.css +3481 -0
  24. package/templates/base/apps/admin/src/main.tsx.ejs +3 -3
  25. package/templates/base/apps/admin/src/vite-env.d.ts +19 -0
  26. package/templates/base/apps/admin/tailwind.config.js +9 -0
  27. package/templates/base/apps/admin/vite.config.ts.ejs +21 -2
  28. package/templates/base/apps/ai-server/main.py.ejs +94 -6
  29. package/templates/base/apps/backend/package.json.ejs +27 -0
  30. package/templates/base/apps/frontend/package.json.ejs +7 -0
  31. package/templates/base/apps/frontend/vite.config.ts.ejs +0 -1
  32. package/templates/base/docker-compose.yml.ejs +6 -1
  33. package/templates/base/nexus.config.ts.ejs +4 -4
  34. package/templates/features/auth/apps/backend/src/models/User.ts +21 -0
  35. package/templates/features/auth/apps/backend/src/routes/auth.ts +95 -0
  36. package/templates/features/email/apps/backend/src/mail/mailables/WelcomeMail.ts +25 -0
  37. package/templates/features/email/apps/backend/src/mail/templates/welcome.ejs.ejs +10 -0
  38. package/templates/features/graphql/apps/backend/src/graphql/post.graph.ts +61 -0
  39. package/templates/features/graphql/apps/backend/src/models/Post.ts +15 -0
  40. package/templates/features/payments/apps/backend/src/routes/payments.ts +45 -0
  41. package/templates/features/queue/apps/backend/src/events/JobQueued.ts +14 -0
  42. package/templates/features/queue/apps/backend/src/jobs/ExampleJob.ts +18 -0
  43. package/templates/features/queue/apps/backend/src/listeners/OnJobQueued.ts +12 -0
  44. package/templates/features/realtime/apps/backend/src/models/Message.ts +14 -0
  45. package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +56 -0
  46. package/templates/features/storage/apps/backend/src/routes/uploads.ts +91 -0
package/src/devPanel.ts CHANGED
@@ -1,49 +1,31 @@
1
1
  /**
2
- * devPanel — full-screen Nexus Console TUI.
2
+ * devPanel — full-screen Nexus Console TUI (redesigned).
3
3
  *
4
- * Three panes:
5
- * 1. Servicesmanaged by ServiceManager (start/stop/restart/logs)
6
- * 2. Commands — palette auto-built from the command registry (getCommands())
7
- * 3. Output — selected service logs OR command output
4
+ * Two-pane layout:
5
+ * LEFT tab railServices · Logs · Commands · Features · Settings
6
+ * RIGHT content for the active tab
8
7
  *
9
8
  * Keybindings:
10
- * ↑/↓ move selection (within active list)
11
- * Enter run selected command / start selected service
12
- * Tab switch active pane (services commands)
13
- * s / t / r start / stop / restart selected service
14
- * l cycle log source (service logs ⇄ command output)
15
- * q / Esc quit (kills all services)
16
- * ? help
9
+ * Tab / / → switch tab
10
+ * ↑/↓ move selection in the active tab
11
+ * Enter run selected command / start service / install feature
12
+ * s / t / r start / stop / restart selected service
13
+ * l jump to Logs
14
+ * a install selected feature (Features tab)
15
+ * d run doctor (Settings tab)
16
+ * ? help
17
+ * q / Esc quit (kills all services)
17
18
  *
18
- * Zero dependencies: raw ANSI + readline keypress.
19
+ * Zero dependencies: raw ANSI + readline keypress via src/tui.ts.
19
20
  */
20
- import { stdin as input, stdout as output } from 'node:process';
21
- import { spawn } from 'node:child_process';
22
21
  import { existsSync } from 'node:fs';
23
- import { fileURLToPath } from 'node:url';
24
- import { dirname, join } from 'node:path';
25
- import * as readline from 'node:readline';
22
+ import { join } from 'node:path';
23
+ import { stdout as output } from 'node:process';
24
+ import { ANSI, Tui, stripAnsi, resolveCliBin, spawnNode, type KeyInfo } from './tui.js';
25
+ import { fitCell, clipVisible, divider, boxAround } from './layout.js';
26
26
  import { getCommands, runCommand } from './dispatcher.js';
27
27
  import { ServiceManager } from './devServiceManager.js';
28
-
29
- const ANSI = {
30
- reset: '\x1b[0m',
31
- bold: '\x1b[1m',
32
- dim: '\x1b[2m',
33
- clear: '\x1b[2J\x1b[H',
34
- altOn: '\x1b[?1049h',
35
- altOff: '\x1b[?1049l',
36
- hideCursor: '\x1b[?25l',
37
- showCursor: '\x1b[?25h',
38
- move: (r: number, c: number) => `\x1b[${r};${c}H`,
39
- cyan: '\x1b[36m',
40
- green: '\x1b[32m',
41
- yellow: '\x1b[33m',
42
- red: '\x1b[31m',
43
- blue: '\x1b[34m',
44
- magenta: '\x1b[35m',
45
- dimGray: '\x1b[90m',
46
- };
28
+ import { FEATURES, detectFeatures, installFeature, readRegistry } from './features.js';
47
29
 
48
30
  export interface DevPanelOptions {
49
31
  services: ServiceManager;
@@ -51,12 +33,27 @@ export interface DevPanelOptions {
51
33
  onQuit?: () => void;
52
34
  }
53
35
 
54
- interface CommandItem {
55
- name: string;
56
- label: string;
57
- desc: string;
58
- needsArgs: boolean;
59
- interactive: boolean;
36
+ type Tab = 'services' | 'logs' | 'commands' | 'features' | 'settings';
37
+
38
+ const TABS: Array<{ id: Tab; label: string }> = [
39
+ { id: 'services', label: 'Services' },
40
+ { id: 'logs', label: 'Logs' },
41
+ { id: 'commands', label: 'Commands' },
42
+ { id: 'features', label: 'Features' },
43
+ { id: 'settings', label: 'Settings' },
44
+ ];
45
+
46
+ /** Per-tab color theme: active tab fills with `bg` + `fg` text; content tints with `accent`. */
47
+ const TAB_THEME: Record<Tab, { bg: string; fg: string; accent: string }> = {
48
+ services: { bg: ANSI.bgCyan, fg: ANSI.black, accent: ANSI.cyan },
49
+ logs: { bg: ANSI.bgYellow, fg: ANSI.black, accent: ANSI.yellow },
50
+ commands: { bg: ANSI.bgMagenta, fg: ANSI.white, accent: ANSI.magenta },
51
+ features: { bg: ANSI.bgGreen, fg: ANSI.black, accent: ANSI.green },
52
+ settings: { bg: ANSI.bgBlue, fg: ANSI.white, accent: ANSI.blue },
53
+ };
54
+
55
+ function tabAccent(tab: Tab): string {
56
+ return TAB_THEME[tab]?.accent ?? ANSI.cyan;
60
57
  }
61
58
 
62
59
  /** Commands that need a real TTY (their own prompts) — suspend the panel. */
@@ -68,35 +65,394 @@ const ARG_COMMANDS = new Set([
68
65
  'make:middleware', 'make:validator', 'make:job', 'make:event', 'make:listener',
69
66
  'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
70
67
  'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
71
- 'db:seed', 'db:migrate', 'db:rollback', 'queue:retry',
68
+ 'db:seed', 'db:migrate', 'db:rollback', 'queue:retry', 'add',
72
69
  ]);
73
70
 
71
+ interface CommandItem {
72
+ name: string;
73
+ desc: string;
74
+ needsArgs: boolean;
75
+ interactive: boolean;
76
+ }
77
+
78
+ interface PanelState {
79
+ tab: Tab;
80
+ svcIndex: number;
81
+ cmdIndex: number;
82
+ featIndex: number;
83
+ installed: Set<string>;
84
+ featureMsg: string[];
85
+ settingsLines: string[];
86
+ commandOutput: string[];
87
+ argMode: { cmd: CommandItem; buffer: string } | null;
88
+ showHelp: boolean;
89
+ confirmQuit: boolean;
90
+ quitIndex: number;
91
+ quit: boolean;
92
+ }
93
+
94
+ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
95
+ const { services } = opts;
96
+ const projectRoot = process.cwd();
97
+ const commands = commandItems();
98
+ const grouped = groupCommands(commands);
99
+ const palette = flattenPalette(grouped);
100
+
101
+ const installed = await detectFeatures(projectRoot);
102
+ const state: PanelState = {
103
+ tab: 'services',
104
+ svcIndex: 0,
105
+ cmdIndex: 0,
106
+ featIndex: 0,
107
+ installed,
108
+ featureMsg: [],
109
+ settingsLines: [],
110
+ commandOutput: [],
111
+ argMode: null,
112
+ showHelp: false,
113
+ confirmQuit: false,
114
+ quitIndex: 0,
115
+ quit: false,
116
+ };
117
+
118
+ let lastRender = 0;
119
+
120
+ function throttledRender(): void {
121
+ const now = Date.now();
122
+ if (now - lastRender < 80) return;
123
+ lastRender = now;
124
+ render();
125
+ }
126
+
127
+ function render(): void {
128
+ if (state.quit) return;
129
+ tui.draw(buildRows(state, services, palette, projectRoot), helpText(state));
130
+ }
131
+
132
+ const tui = new Tui((str, key) => {
133
+ onKey(state, str, key, { services, palette, projectRoot, tui, redraw: render });
134
+ });
135
+
136
+ // wire service log redraws
137
+ services.onLog = () => throttledRender();
138
+
139
+ await refreshSettings(state, projectRoot, render);
140
+ tui.enter();
141
+ render();
142
+
143
+ await tui.wait();
144
+ tui.exit();
145
+ services.onLog = null;
146
+ opts.onQuit?.();
147
+ }
148
+
149
+ interface Ctx {
150
+ services: ServiceManager;
151
+ palette: PaletteRow[];
152
+ projectRoot: string;
153
+ tui: Tui;
154
+ redraw: () => void;
155
+ }
156
+
157
+ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
158
+ // ---- quit confirmation card ----
159
+ if (state.confirmQuit) {
160
+ if (key.name === 'escape' || key.name === 'q') {
161
+ state.confirmQuit = false;
162
+ ctx.redraw();
163
+ return;
164
+ }
165
+ if (key.name === 'up' || key.name === 'left') {
166
+ state.quitIndex = 0;
167
+ ctx.redraw();
168
+ return;
169
+ }
170
+ if (key.name === 'down' || key.name === 'right') {
171
+ state.quitIndex = 1;
172
+ ctx.redraw();
173
+ return;
174
+ }
175
+ if (key.name === 'return' || key.name === 'enter') {
176
+ if (state.quitIndex === 0) {
177
+ state.quit = true; // Yes — quit
178
+ ctx.tui.quit = true;
179
+ } else {
180
+ state.confirmQuit = false; // No — stay
181
+ }
182
+ return;
183
+ }
184
+ if (key.name === 'c' && key.ctrl) {
185
+ // Second Ctrl+C inside the card = force quit immediately.
186
+ state.quit = true;
187
+ ctx.tui.quit = true;
188
+ return;
189
+ }
190
+ return;
191
+ }
192
+
193
+ if (state.argMode) {
194
+ handleArgMode(state, str, key, ctx);
195
+ ctx.redraw();
196
+ return;
197
+ }
198
+ if (key.name === 'c' && key.ctrl) {
199
+ state.confirmQuit = true;
200
+ state.quitIndex = 0;
201
+ ctx.redraw();
202
+ return;
203
+ }
204
+ if (key.name === 'q') {
205
+ state.confirmQuit = true;
206
+ state.quitIndex = 0;
207
+ ctx.redraw();
208
+ return;
209
+ }
210
+ if (key.name === 'escape') {
211
+ if (state.showHelp) { state.showHelp = false; ctx.redraw(); }
212
+ else { state.confirmQuit = true; state.quitIndex = 0; ctx.redraw(); }
213
+ return;
214
+ }
215
+ if (key.name === '?') {
216
+ state.showHelp = !state.showHelp;
217
+ ctx.redraw();
218
+ return;
219
+ }
220
+ if (state.showHelp) return;
221
+
222
+ switch (key.name) {
223
+ case 'tab': {
224
+ const idx = TABS.findIndex((t) => t.id === state.tab);
225
+ state.tab = TABS[(idx + 1) % TABS.length]?.id ?? 'services';
226
+ if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
227
+ break;
228
+ }
229
+ case 'left': {
230
+ const idx = TABS.findIndex((t) => t.id === state.tab);
231
+ state.tab = TABS[(idx - 1 + TABS.length) % TABS.length]?.id ?? 'services';
232
+ if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
233
+ break;
234
+ }
235
+ case 'right': {
236
+ const idx = TABS.findIndex((t) => t.id === state.tab);
237
+ state.tab = TABS[(idx + 1) % TABS.length]?.id ?? 'services';
238
+ if (state.tab === 'settings') void refreshSettings(state, ctx.projectRoot, ctx.redraw);
239
+ break;
240
+ }
241
+ case 'up':
242
+ move(state, -1, ctx);
243
+ break;
244
+ case 'down':
245
+ move(state, 1, ctx);
246
+ break;
247
+ case 'return':
248
+ case 'enter':
249
+ void activate(state, ctx);
250
+ break;
251
+ case 's':
252
+ ctx.services.start(ctx.services.services[state.svcIndex]?.name ?? '');
253
+ break;
254
+ case 't':
255
+ ctx.services.stop(ctx.services.services[state.svcIndex]?.name ?? '');
256
+ break;
257
+ case 'r':
258
+ ctx.services.restart(ctx.services.services[state.svcIndex]?.name ?? '');
259
+ break;
260
+ case 'l':
261
+ state.tab = 'logs';
262
+ break;
263
+ case 'a':
264
+ if (state.tab === 'features') void installSelected(state, ctx);
265
+ break;
266
+ case 'd':
267
+ if (state.tab === 'settings') void runSpawned('doctor', [], ctx);
268
+ break;
269
+ }
270
+ ctx.redraw();
271
+ }
272
+
273
+ function move(state: PanelState, dir: number, ctx: Ctx): void {
274
+ switch (state.tab) {
275
+ case 'services':
276
+ case 'logs':
277
+ state.svcIndex = clamp(state.svcIndex + dir, 0, Math.max(0, ctx.services.services.length - 1));
278
+ break;
279
+ case 'commands':
280
+ state.cmdIndex = prevSelectable(ctx.palette, state.cmdIndex, dir);
281
+ break;
282
+ case 'features':
283
+ state.featIndex = clamp(state.featIndex + dir, 0, FEATURES.length - 1);
284
+ break;
285
+ }
286
+ }
287
+
288
+ function handleArgMode(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
289
+ const argMode = state.argMode;
290
+ if (!argMode) return;
291
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
292
+ state.argMode = null;
293
+ return;
294
+ }
295
+ if (key.name === 'return' || key.name === 'enter') {
296
+ const cmd = argMode.cmd;
297
+ state.argMode = null;
298
+ void runSelectedCommand(state, cmd, argMode.buffer, ctx);
299
+ return;
300
+ }
301
+ if (key.name === 'backspace') {
302
+ argMode.buffer = argMode.buffer.slice(0, -1);
303
+ } else if (str && !key.ctrl && !key.meta) {
304
+ argMode.buffer += str;
305
+ }
306
+ }
307
+
308
+ async function activate(state: PanelState, ctx: Ctx): Promise<void> {
309
+ switch (state.tab) {
310
+ case 'services':
311
+ case 'logs': {
312
+ const svc = ctx.services.services[state.svcIndex];
313
+ if (svc) {
314
+ if (svc.status === 'stopped' || svc.status === 'crashed') ctx.services.start(svc.name);
315
+ else ctx.services.restart(svc.name);
316
+ }
317
+ break;
318
+ }
319
+ case 'commands': {
320
+ const row = ctx.palette[state.cmdIndex];
321
+ if (row && row.kind === 'cmd') {
322
+ if (row.cmd.needsArgs) state.argMode = { cmd: row.cmd, buffer: '' };
323
+ else void runSelectedCommand(state, row.cmd, '', ctx);
324
+ }
325
+ break;
326
+ }
327
+ case 'features':
328
+ await installSelected(state, ctx);
329
+ break;
330
+ }
331
+ }
332
+
333
+ async function installSelected(state: PanelState, ctx: Ctx): Promise<void> {
334
+ const id = FEATURES[state.featIndex]?.id;
335
+ if (!id) return;
336
+ if (state.installed.has(id)) {
337
+ state.featureMsg = [`${id} is already installed.`];
338
+ ctx.redraw();
339
+ return;
340
+ }
341
+ const res = await installFeature(process.cwd(), id);
342
+ state.featureMsg = [...res.messages];
343
+ if (res.addedApps.length > 0) {
344
+ state.featureMsg.push('', 'Restart the console (q, then npm run dev) to pick up new apps.');
345
+ }
346
+ state.installed = await detectFeatures(process.cwd());
347
+ ctx.redraw();
348
+ }
349
+
350
+ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArgs: string, ctx: Ctx): Promise<void> {
351
+ state.commandOutput = [];
352
+ ctx.redraw();
353
+
354
+ if (cmd.interactive) {
355
+ // Suspend panel → real TTY → resume.
356
+ ctx.tui.suspend();
357
+ try {
358
+ const bin = resolveCliBin();
359
+ const args = inlineArgs ? [cmd.name, ...inlineArgs.trim().split(/\s+/)] : [cmd.name];
360
+ await spawnNode(bin, args);
361
+ } catch (err) {
362
+ push(state, `✗ ${cmd.name} failed: ${(err as Error).message}`);
363
+ }
364
+ ctx.tui.resume();
365
+ ctx.redraw();
366
+ return;
367
+ }
368
+
369
+ const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
370
+ const origOut = process.stdout.write.bind(process.stdout);
371
+ const origErr = process.stderr.write.bind(process.stderr);
372
+ const sink = (chunk: string | Buffer) => {
373
+ push(state, String(chunk));
374
+ return true;
375
+ };
376
+ (process.stdout as unknown as { write: Function }).write = sink;
377
+ (process.stderr as unknown as { write: Function }).write = sink;
378
+ try {
379
+ await runCommand(cmd.name, argv);
380
+ push(state, `\n${ANSI.green}✓ ${cmd.name}${ANSI.reset} finished`);
381
+ } catch (err) {
382
+ push(state, `\n${ANSI.red}✗ ${cmd.name} failed:${ANSI.reset} ${(err as Error).message}`);
383
+ } finally {
384
+ (process.stdout as unknown as { write: Function }).write = origOut;
385
+ (process.stderr as unknown as { write: Function }).write = origErr;
386
+ }
387
+ ctx.redraw();
388
+ }
389
+
390
+ function push(state: PanelState, text: string): void {
391
+ for (const line of text.split('\n')) {
392
+ state.commandOutput.push(stripAnsi(line));
393
+ }
394
+ if (state.commandOutput.length > 200) state.commandOutput.splice(0, state.commandOutput.length - 200);
395
+ }
396
+
397
+ async function runSpawned(cmd: string, args: string[], ctx: Ctx): Promise<void> {
398
+ ctx.tui.suspend();
399
+ try {
400
+ const bin = resolveCliBin();
401
+ await spawnNode(bin, [cmd, ...args]);
402
+ } catch { /* ignore */ }
403
+ ctx.tui.resume();
404
+ ctx.redraw();
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Settings
409
+ // ---------------------------------------------------------------------------
410
+
411
+ async function refreshSettings(state: PanelState, projectRoot: string, redraw: () => void): Promise<void> {
412
+ const reg = await readRegistry(projectRoot);
413
+ const lines: string[] = [
414
+ `${ANSI.bold}Project${ANSI.reset}`,
415
+ ` ${projectRoot}`,
416
+ '',
417
+ `${ANSI.bold}Ports (.nexus-ports.json)${ANSI.reset}`,
418
+ ];
419
+ for (const [app, port] of Object.entries(reg)) {
420
+ lines.push(` ${app.padEnd(18)} ${port}`);
421
+ }
422
+ if (Object.keys(reg).length === 0) lines.push(` ${ANSI.dim}No ports registered.${ANSI.reset}`);
423
+ lines.push('', `${ANSI.bold}Environment${ANSI.reset}`);
424
+ lines.push(` ${existsSync(join(projectRoot, '.env')) ? `${ANSI.dim}.env present${ANSI.reset}` : `${ANSI.dim}.env not found${ANSI.reset}`}`);
425
+ state.settingsLines = lines;
426
+ redraw();
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Commands palette
431
+ // ---------------------------------------------------------------------------
432
+
74
433
  function commandItems(): CommandItem[] {
75
434
  return getCommands().map((c) => ({
76
435
  name: c.name,
77
- label: c.name,
78
436
  desc: c.description ?? '',
79
437
  needsArgs: ARG_COMMANDS.has(c.name),
80
438
  interactive: INTERACTIVE_COMMANDS.has(c.name),
81
439
  }));
82
440
  }
83
441
 
84
- /** Categorize commands for the palette grouping. */
85
442
  function groupOf(name: string): string {
86
443
  if (name.startsWith('make:')) return 'make:*';
87
444
  if (name.startsWith('db:')) return 'data';
88
445
  if (name.startsWith('queue:')) return 'queue';
89
446
  if (name.startsWith('plugin')) return 'plugins';
90
447
  if (name === 'down' || name === 'up') return 'maintenance';
91
- if (['init', 'dev', 'build', 'test', 'doctor'].includes(name)) return 'project';
92
448
  return 'project';
93
449
  }
94
450
 
95
451
  const GROUPS = ['project', 'make:*', 'data', 'queue', 'plugins', 'maintenance'];
96
452
 
97
- export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
98
- const { services } = opts;
99
- const items = commandItems();
453
+ type PaletteRow = { kind: 'header'; label: string } | { kind: 'cmd'; cmd: CommandItem };
454
+
455
+ function groupCommands(items: CommandItem[]): Map<string, CommandItem[]> {
100
456
  const grouped = new Map<string, CommandItem[]>();
101
457
  for (const g of GROUPS) grouped.set(g, []);
102
458
  for (const item of items) {
@@ -104,337 +460,237 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
104
460
  if (!grouped.has(g)) grouped.set(g, []);
105
461
  grouped.get(g)!.push(item);
106
462
  }
463
+ return grouped;
464
+ }
107
465
 
108
- // Flatten palette (with group header markers).
109
- type Row = { kind: 'header'; label: string } | { kind: 'cmd'; cmd: CommandItem };
110
- const palette: Row[] = [];
466
+ function flattenPalette(grouped: Map<string, CommandItem[]>): PaletteRow[] {
467
+ const palette: PaletteRow[] = [];
111
468
  for (const g of GROUPS) {
112
469
  const list = grouped.get(g) ?? [];
113
470
  if (list.length === 0) continue;
114
471
  palette.push({ kind: 'header', label: g });
115
472
  for (const cmd of list) palette.push({ kind: 'cmd', cmd });
116
473
  }
474
+ return palette;
475
+ }
117
476
 
118
- // ---- state ----
119
- let activePane: 'services' | 'commands' = 'services';
120
- let svcIndex = 0;
121
- let cmdIndex = 0;
122
- let logSource: string | 'commands' = services.services[0]?.name ?? 'commands';
123
- let commandOutput: string[] = [];
124
- let argMode: { cmd: CommandItem; buffer: string } | null = null;
125
- let showHelp = false;
126
- let quit = false;
127
- let lastRender = 0;
128
-
129
- // ---- raw keyboard ----
130
- input.setRawMode?.(true);
131
- input.resume?.();
132
- output.write(ANSI.altOn + ANSI.hideCursor);
133
-
134
- const keypressHandler = (str: string, key: any) => {
135
- if (argMode) {
136
- if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
137
- argMode = null;
138
- render();
139
- } else if (key.name === 'return' || key.name === 'enter') {
140
- const { cmd, buffer } = argMode;
141
- argMode = null;
142
- void runSelectedCommand(cmd, buffer);
143
- } else if (key.name === 'backspace') {
144
- argMode.buffer = argMode.buffer.slice(0, -1);
145
- } else if (str && !key.ctrl && !key.meta) {
146
- argMode.buffer += str;
147
- }
148
- render();
149
- return;
150
- }
151
-
152
- if (key.name === 'c' && key.ctrl) {
153
- quit = true;
154
- return;
155
- }
156
- if (key.name === 'escape' || key.name === 'q') {
157
- if (showHelp) showHelp = false;
158
- else quit = true;
159
- return;
160
- }
161
- if (key.name === '?') {
162
- showHelp = !showHelp;
163
- }
164
- if (showHelp) return;
165
-
166
- switch (key.name) {
167
- case 'up':
168
- if (activePane === 'services') svcIndex = Math.max(0, svcIndex - 1);
169
- else cmdIndex = prevSelectable(cmdIndex, -1, palette);
170
- break;
171
- case 'down':
172
- if (activePane === 'services') svcIndex = Math.min(services.services.length - 1, svcIndex + 1);
173
- else cmdIndex = prevSelectable(cmdIndex, 1, palette);
174
- break;
175
- case 'tab':
176
- activePane = activePane === 'services' ? 'commands' : 'services';
177
- break;
178
- case 'return':
179
- case 'enter':
180
- if (activePane === 'services') {
181
- const svc = services.services[svcIndex];
182
- if (svc) {
183
- if (svc.status === 'stopped' || svc.status === 'crashed') services.start(svc.name);
184
- else services.restart(svc.name);
185
- }
186
- } else {
187
- const row = palette[cmdIndex];
188
- if (row && row.kind === 'cmd') {
189
- const cmd = row.cmd;
190
- if (cmd.needsArgs) {
191
- argMode = { cmd, buffer: '' };
192
- } else {
193
- void runSelectedCommand(cmd, '');
194
- }
195
- }
196
- }
197
- break;
198
- case 's':
199
- services.start(services.services[svcIndex]?.name ?? '');
200
- break;
201
- case 't':
202
- services.stop(services.services[svcIndex]?.name ?? '');
203
- break;
204
- case 'r':
205
- services.restart(services.services[svcIndex]?.name ?? '');
206
- break;
207
- case 'l':
208
- cycleLogSource();
209
- break;
210
- }
211
- render();
212
- };
213
-
214
- function prevSelectable(current: number, dir: number, list: Row[]): number {
215
- let i = current;
216
- for (let step = 0; step < list.length; step++) {
217
- i = (i + dir + list.length) % list.length;
218
- const row = list[i];
219
- if (row && row.kind === 'cmd') return i;
220
- }
221
- return current;
477
+ function prevSelectable(list: PaletteRow[], current: number, dir: number): number {
478
+ let i = current;
479
+ for (let step = 0; step < list.length; step++) {
480
+ i = (i + dir + list.length) % list.length;
481
+ const row = list[i];
482
+ if (row && row.kind === 'cmd') return i;
222
483
  }
484
+ return current;
485
+ }
223
486
 
224
- function cycleLogSource(): void {
225
- const names = services.services.map((s) => s.name);
226
- const targets = [...names, 'commands'];
227
- const idx = targets.indexOf(logSource);
228
- logSource = targets[(idx + 1) % targets.length] ?? 'commands';
487
+ // ---------------------------------------------------------------------------
488
+ // Rendering
489
+ // ---------------------------------------------------------------------------
490
+
491
+ const RAIL_W = 20;
492
+
493
+ function buildRows(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string): string[] {
494
+ const W = output.columns || 80;
495
+ const contentW = Math.max(W - 2, 20);
496
+ const rows: string[] = [];
497
+ rows.push(`${ANSI.bold}${ANSI.cyan} Nexus Console${ANSI.reset}${ANSI.dim} — BhooAI Nexus · ${services.services.length} service(s)${ANSI.reset}`);
498
+ rows.push('');
499
+ rows.push(renderTabBar(state, W));
500
+ rows.push(divider(W));
501
+ rows.push('');
502
+
503
+ const content = renderContent(state, services, palette, projectRoot, contentW);
504
+ for (const line of content) rows.push(clipVisible(line, contentW));
505
+
506
+ // Crash banner: show if any service is in crashed state.
507
+ const crashed = services.services.filter((s) => s.status === 'crashed');
508
+ if (crashed.length > 0) {
509
+ rows.push('');
510
+ const msg = ` ⚠ ${crashed.length} service(s) crashed — select and press s to restart `;
511
+ rows.push(`${ANSI.bgRed}${ANSI.white}${ANSI.bold}${fitCell(msg, contentW)}${ANSI.reset}`);
229
512
  }
230
513
 
231
- async function runSelectedCommand(cmd: CommandItem, inlineArgs: string): Promise<void> {
232
- commandOutput = [];
233
- setLogSource('commands');
234
- render();
235
-
236
- if (cmd.interactive) {
237
- // Suspend panel → real TTY → resume.
238
- suspend();
239
- try {
240
- const bin = resolveCliBin();
241
- const args = inlineArgs ? [cmd.name, ...inlineArgs.trim().split(/\s+/)] : [cmd.name];
242
- await spawnNode(bin, args);
243
- } catch (err) {
244
- pushCommandOutput(`✗ ${cmd.name} failed: ${(err as Error).message}`);
245
- }
246
- resume();
247
- render();
248
- return;
249
- }
250
-
251
- const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
252
- const origOut = process.stdout.write.bind(process.stdout);
253
- const origErr = process.stderr.write.bind(process.stderr);
254
- const sink = (chunk: string | Buffer, isErr = false) => {
255
- const text = String(chunk);
256
- pushCommandOutput(text);
257
- return true;
258
- };
259
- (process.stdout as unknown as { write: Function }).write = (chunk: string | Buffer) => sink(chunk, false);
260
- (process.stderr as unknown as { write: Function }).write = (chunk: string | Buffer) => sink(chunk, true);
261
- try {
262
- await runCommand(cmd.name, argv);
263
- pushCommandOutput(`\n${ANSI.green}✓ ${cmd.name}${ANSI.reset} finished`);
264
- } catch (err) {
265
- pushCommandOutput(`\n${ANSI.red}✗ ${cmd.name} failed:${ANSI.reset} ${(err as Error).message}`);
266
- } finally {
267
- (process.stdout as unknown as { write: Function }).write = origOut;
268
- (process.stderr as unknown as { write: Function }).write = origErr;
269
- }
270
- render();
514
+ if (state.confirmQuit) {
515
+ rows.push('');
516
+ rows.push(...renderQuitCard(state, contentW));
271
517
  }
272
-
273
- function setLogSource(source: string): void {
274
- logSource = source;
518
+ if (state.argMode) {
519
+ rows.push('');
520
+ rows.push(`${ANSI.cyan}${state.argMode.cmd.name}${ANSI.reset} ${state.argMode.buffer}█`);
275
521
  }
276
-
277
- function pushCommandOutput(text: string): void {
278
- const lines = text.split('\n');
279
- for (const line of lines) {
280
- commandOutput.push(stripAnsi(line));
281
- }
282
- if (commandOutput.length > 200) commandOutput.splice(0, commandOutput.length - 200);
283
- throttledRender();
522
+ if (state.showHelp) {
523
+ rows.push('');
524
+ rows.push(helpBody());
284
525
  }
526
+ return rows;
527
+ }
528
+
529
+ /** A bordered confirmation card asking whether to quit (kills all services). */
530
+ function renderQuitCard(state: PanelState, contentW: number): string[] {
531
+ const innerW = Math.min(Math.max(contentW - 6, 30), 50);
532
+ const inner: string[] = [
533
+ `${ANSI.bold}${ANSI.yellow}Quit the dev console?${ANSI.reset}`,
534
+ '',
535
+ `${ANSI.dim}All running services will be stopped.${ANSI.reset}`,
536
+ '',
537
+ ];
538
+ const options = ['Yes, quit', 'Cancel'];
539
+ options.forEach((label, i) => {
540
+ const sel = i === state.quitIndex;
541
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
542
+ const text = sel ? `${ANSI.bold}${label}${ANSI.reset}` : label;
543
+ inner.push(`${marker} ${text}`);
544
+ });
545
+ return boxAround(inner, innerW);
546
+ }
285
547
 
286
- function suspend(): void {
287
- input.setRawMode?.(false);
288
- output.write(ANSI.altOff + ANSI.showCursor);
548
+ /** Horizontal tab bar across the top. Active tab = filled background in its theme; others = dim. */
549
+ function renderTabBar(state: PanelState, W: number): string {
550
+ const cellW = Math.max(Math.floor(W / TABS.length), 8);
551
+ const parts: string[] = [];
552
+ for (const t of TABS) {
553
+ const theme = TAB_THEME[t.id];
554
+ const active = t.id === state.tab;
555
+ if (active && theme) {
556
+ // Filled cell: bg + readable fg + bold label, padded with bg spaces.
557
+ const label = ` ${t.label} `;
558
+ const cell = `${theme.bg}${theme.fg}${ANSI.bold}${fitCell(label, cellW)}${ANSI.reset}`;
559
+ parts.push(cell);
560
+ } else {
561
+ const accent = theme?.accent ?? ANSI.dimGray;
562
+ const label = ` ${t.label} `;
563
+ const cell = `${ANSI.dim}${accent}${fitCell(label, cellW)}${ANSI.reset}`;
564
+ parts.push(cell);
565
+ }
289
566
  }
567
+ // Join with single spaces; clip to width.
568
+ const bar = parts.join(' ');
569
+ return clipVisible(bar, W);
570
+ }
290
571
 
291
- function resume(): void {
292
- input.setRawMode?.(true);
293
- input.resume?.();
294
- output.write(ANSI.altOn + ANSI.hideCursor);
572
+ function renderContent(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string, contentW: number): string[] {
573
+ const accent = tabAccent(state.tab);
574
+ switch (state.tab) {
575
+ case 'services': return renderServices(state, services, accent, contentW);
576
+ case 'logs': return renderLogs(state, services, accent, contentW);
577
+ case 'commands': return renderCommands(state, palette, accent, contentW);
578
+ case 'features': return renderFeaturesTab(state, accent, contentW);
579
+ case 'settings': return renderSettingsTab(state, accent);
295
580
  }
581
+ }
296
582
 
297
- // ---- render ----
298
- function throttledRender(): void {
299
- const now = Date.now();
300
- if (now - lastRender < 80) return;
301
- lastRender = now;
302
- render();
583
+ function statusDot(status: string): string {
584
+ switch (status) {
585
+ case 'running': return `${ANSI.green}●${ANSI.reset}`;
586
+ case 'starting': return `${ANSI.yellow}◐${ANSI.reset}`;
587
+ case 'crashed': return `${ANSI.red}✗${ANSI.reset}`;
588
+ default: return `${ANSI.dimGray}○${ANSI.reset}`;
303
589
  }
590
+ }
304
591
 
305
- function render(): void {
306
- if (quit) return;
307
- if (!output.isTTY) return;
308
- const { rows, columns } = output as unknown as { rows: number; columns: number };
309
- const H = rows || 24;
310
- const W = columns || 80;
311
-
312
- const lines: string[] = [];
313
- lines.push(`${ANSI.bold}${ANSI.cyan} Nexus Console ${ANSI.reset}${ANSI.dim}— BhooAI Nexus${ANSI.reset} ${ANSI.dim}(${services.services.length} services, ${palette.length - GROUPS.length} commands)${ANSI.reset}`);
314
- lines.push('');
315
-
316
- // --- Services pane ---
317
- lines.push(`${ANSI.bold}${ANSI.blue} SERVICES ${ANSI.reset}`);
318
- services.services.forEach((svc, i) => {
319
- const sel = activePane === 'services' && i === svcIndex;
320
- const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
321
- const dot = statusDot(svc.status);
322
- const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
323
- const pid = svc.pid ? String(svc.pid) : '—';
324
- const restarts = svc.restarts ? String(svc.restarts) : '0';
325
- lines.push(` ${marker} ${name.padEnd(18)} :${String(svc.port).padEnd(6)} ${dot} ${svc.status.padEnd(8)} ${pid.padEnd(7)} ${restarts}`);
326
- });
327
- lines.push('');
328
-
329
- // --- Commands pane ---
330
- lines.push(`${ANSI.bold}${ANSI.magenta} COMMANDS ${ANSI.reset}`);
331
- const visible = palette.slice(0, Math.min(palette.length, H - lines.length - 8));
332
- visible.forEach((row, i) => {
333
- if (row.kind === 'header') {
334
- lines.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
335
- } else {
336
- const isSelected = activePane === 'commands' && palette.indexOf(row) === cmdIndex;
337
- const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
338
- const label = isSelected ? `${ANSI.bold}${row.cmd.label}${ANSI.reset}` : row.cmd.label;
339
- const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
340
- lines.push(` ${marker} ${label.padEnd(22)} ${desc}`);
592
+ function renderServices(state: PanelState, services: ServiceManager, accent: string, _contentW: number): string[] {
593
+ const out = [`${ANSI.bold}${accent} SERVICES${ANSI.reset}${ANSI.dim} (s start · t stop · r restart)${ANSI.reset}`, ''];
594
+ services.services.forEach((svc, i) => {
595
+ const sel = i === state.svcIndex;
596
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
597
+ const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
598
+ const pid = svc.pid ? String(svc.pid) : '—';
599
+ const restarts = svc.restarts ? String(svc.restarts) : '0';
600
+ const exitCode = svc.lastExit?.code != null ? ` exit:${svc.lastExit.code}` : '';
601
+ const exitTag = svc.status === 'crashed' ? `${ANSI.red}${exitCode}${ANSI.reset}` : '';
602
+ out.push(` ${marker} ${fitCell(name, 18)} :${fitCell(String(svc.port), 6)} ${statusDot(svc.status)} ${fitCell(svc.status, 8)} ${fitCell(pid, 7)} ${restarts}${exitTag}`);
603
+ // Inline crash log under the crashed service row.
604
+ if (svc.status === 'crashed' && svc.lastCrashLog.length > 0) {
605
+ for (const line of svc.lastCrashLog.slice(-4)) {
606
+ out.push(` ${ANSI.red}│${ANSI.reset} ${clipVisible(line, _contentW - 6)}`);
341
607
  }
342
- });
343
- lines.push('');
344
-
345
- // --- Output pane ---
346
- const outputTitle = logSource === 'commands' ? 'COMMAND OUTPUT' : `LOGS: ${logSource}`;
347
- lines.push(`${ANSI.bold}${ANSI.yellow} ${outputTitle} ${ANSI.reset}`);
348
- const src = logSource === 'commands' ? commandOutput : services.tail(logSource, 60);
349
- const availH = H - lines.length - 3;
350
- const tail = src.slice(-Math.max(availH, 1));
351
- for (const line of tail) lines.push(` ${ANSI.dim}|${ANSI.reset} ${line}`);
352
-
353
- // --- help bar ---
354
- const helpText = showHelp
355
- ? ` ↑/↓ select · Enter run/start · Tab pane · s start · t stop · r restart · l logs · q quit · ? help`
356
- : `↑/↓ select · Enter run · Tab pane · s/t/r start/stop/restart · l logs · c cmds · q quit · ? help`;
357
-
358
- // Arg prompt line
359
- if (argMode) {
360
- lines.push('');
361
- lines.push(`${ANSI.cyan}${argMode.cmd.name}${ANSI.reset} ${argMode.buffer}█`);
362
608
  }
609
+ });
610
+ if (services.services.length === 0) out.push(` ${ANSI.dim}No services discovered.${ANSI.reset}`);
611
+ return out;
612
+ }
363
613
 
364
- // Assemble output: keep within terminal height.
365
- const body = lines.slice(0, H - 1);
366
- let out = ANSI.clear;
367
- out += body.join('\n');
368
- out += ANSI.move(H, 1);
369
- out += `${ANSI.dim}${helpText}${ANSI.reset}`;
370
- if (argMode) {
371
- out += ANSI.move(H - 1, 1);
372
- }
373
- output.write(out);
374
- }
614
+ function renderLogs(state: PanelState, services: ServiceManager, accent: string, contentW: number): string[] {
615
+ const out = [`${ANSI.bold}${accent} LOGS${ANSI.reset}${ANSI.dim} (↑/↓ pick service)${ANSI.reset}`, ''];
616
+ services.services.forEach((svc, i) => {
617
+ const sel = i === state.svcIndex;
618
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
619
+ const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
620
+ const exitTag = svc.status === 'crashed' && svc.lastExit?.code != null ? `${ANSI.red} exit:${svc.lastExit.code}${ANSI.reset}` : '';
621
+ out.push(` ${marker} ${statusDot(svc.status)} ${fitCell(name, 18)} :${svc.port}${exitTag}`);
622
+ });
623
+ out.push('');
624
+ const source = services.services[state.svcIndex]?.name ?? '';
625
+ const tail = source ? services.tail(source, 20) : [];
626
+ for (const line of tail) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
627
+ if (tail.length === 0) out.push(` ${ANSI.dim}No logs yet for ${source || 'selected service'}.${ANSI.reset}`);
628
+ return out;
629
+ }
375
630
 
376
- function statusDot(status: string): string {
377
- switch (status) {
378
- case 'running': return `${ANSI.green}●${ANSI.reset}`;
379
- case 'starting': return `${ANSI.yellow}◐${ANSI.reset}`;
380
- case 'crashed': return `${ANSI.red}✗${ANSI.reset}`;
381
- default: return `${ANSI.dimGray}○${ANSI.reset}`;
631
+ function renderCommands(state: PanelState, palette: PaletteRow[], accent: string, contentW: number): string[] {
632
+ const out = [`${ANSI.bold}${accent} COMMANDS${ANSI.reset}${ANSI.dim} (Enter run · args inline)${ANSI.reset}`, ''];
633
+ const visible = palette.slice(0, 18);
634
+ visible.forEach((row) => {
635
+ if (row.kind === 'header') {
636
+ out.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
637
+ } else {
638
+ const isSelected = palette.indexOf(row) === state.cmdIndex;
639
+ const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
640
+ const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
641
+ const arg = row.cmd.needsArgs ? `${ANSI.dim} <name>${ANSI.reset}` : '';
642
+ const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
643
+ const head = ` ${marker} ${fitCell(label, 18)}${arg}`;
644
+ out.push(`${head} ${clipVisible(desc, Math.max(contentW - 22, 8))}`);
382
645
  }
646
+ });
647
+ if (state.commandOutput.length > 0) {
648
+ out.push('');
649
+ out.push(`${ANSI.bold}${ANSI.yellow} OUTPUT${ANSI.reset}`);
650
+ for (const line of state.commandOutput.slice(-12)) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
383
651
  }
652
+ return out;
653
+ }
384
654
 
385
- // wire service log redraws
386
- services.onLog = () => throttledRender();
387
-
388
- // ---- keypress wiring ----
389
- readline.emitKeypressEvents(input);
390
- input.on('keypress', keypressHandler);
391
-
392
- // Intercept SIGINT/SIGTERM (Ctrl+C already handled by keypress ctrl-c).
393
- const onSignal = () => {
394
- quit = true;
395
- };
396
- process.on('SIGINT', onSignal);
397
- process.on('SIGTERM', onSignal);
398
-
399
- render();
400
-
401
- // Wait for quit.
402
- while (!quit) {
403
- await new Promise((r) => setTimeout(r, 100));
655
+ function renderFeaturesTab(state: PanelState, accent: string, contentW: number): string[] {
656
+ const out = [`${ANSI.bold}${accent} FEATURES${ANSI.reset}${ANSI.dim} (a or Enter installs)${ANSI.reset}`, ''];
657
+ const statusW = 12;
658
+ const descW = Math.max(contentW - 18 - statusW - 6, 8);
659
+ FEATURES.forEach((f, i) => {
660
+ const sel = i === state.featIndex;
661
+ const on = state.installed.has(f.id);
662
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
663
+ const status = on ? `${ANSI.green}installed${ANSI.reset}` : `${ANSI.dimGray}available${ANSI.reset}`;
664
+ const name = sel ? `${ANSI.bold}${f.name}${ANSI.reset}` : f.name;
665
+ out.push(` ${marker} ${fitCell(name, 18)} ${fitCell(status, statusW)} ${clipVisible(`${ANSI.dim}${f.desc}${ANSI.reset}`, descW)}`);
666
+ });
667
+ if (state.featureMsg.length > 0) {
668
+ out.push('');
669
+ for (const m of state.featureMsg) out.push(` ${ANSI.dim}·${ANSI.reset} ${clipVisible(m, contentW - 3)}`);
404
670
  }
671
+ return out;
672
+ }
405
673
 
406
- // ---- cleanup ----
407
- input.removeListener('keypress', keypressHandler);
408
- input.setRawMode?.(false);
409
- output.write(ANSI.altOff + ANSI.showCursor);
410
- process.removeListener('SIGINT', onSignal);
411
- process.removeListener('SIGTERM', onSignal);
412
- opts.onQuit?.();
674
+ function renderSettingsTab(state: PanelState, accent: string): string[] {
675
+ return [
676
+ `${ANSI.bold}${accent} SETTINGS${ANSI.reset}${ANSI.dim} (d runs doctor)${ANSI.reset}`,
677
+ '',
678
+ ...state.settingsLines,
679
+ ];
413
680
  }
414
681
 
415
- function spawnNode(bin: string, args: string[]): Promise<void> {
416
- return new Promise((resolve) => {
417
- const child = spawn(process.execPath, [bin, ...args], { stdio: 'inherit', cwd: process.cwd() });
418
- child.on('exit', () => resolve());
419
- child.on('error', () => resolve());
420
- });
682
+ function helpBody(): string {
683
+ return `${ANSI.dim}Tab/←/→ switch tab · ↑/↓ select · Enter run/start/install · s/t/r service controls
684
+ l logs · a install feature · d doctor · ? help · q/Esc quit (kills services)${ANSI.reset}`;
421
685
  }
422
686
 
423
- /**
424
- * Resolve the CLI bin for suspend-mode commands.
425
- *
426
- * Works in the monorepo (packages/nexus-cli/src/devPanel.ts bin/nexus.js)
427
- * AND when installed as @bhooai/nexus-cli (its own bin/nexus.js), falling back
428
- * to `npx` semantics.
429
- */
430
- function resolveCliBin(): string {
431
- const here = dirname(fileURLToPath(import.meta.url));
432
- // From src/devPanel.ts: ../../bin/nexus.js reaches the CLI package's own bin.
433
- const ownBin = join(here, '..', '..', 'bin', 'nexus.js');
434
- if (existsSync(ownBin)) return ownBin;
435
- return join(here, '..', '..', '..', '..', 'bin', 'nexus.js');
687
+ function helpText(state: PanelState): string {
688
+ if (state.confirmQuit) return `${ANSI.dim}↑/↓ choose · Enter confirm · Esc cancel · Ctrl+C force quit${ANSI.reset}`;
689
+ if (state.showHelp) return ` ? help · q quit${ANSI.dim} (kills all services on quit)${ANSI.reset}`;
690
+ if (state.argMode) return `↑/↓ navigate · Enter run · Esc cancel`;
691
+ return `${ANSI.dim}Tab pane · ↑/↓ select · Enter run · s/t/r start/stop/restart · l logs · a add feature · q quit · ? help${ANSI.reset}`;
436
692
  }
437
693
 
438
- function stripAnsi(text: string): string {
439
- return text.replace(/\x1b\[[0-9;]*m/g, '');
694
+ function clamp(n: number, lo: number, hi: number): number {
695
+ return Math.min(Math.max(n, lo), Math.max(lo, hi));
440
696
  }