@bhooai/nexus-cli 2.0.2 → 2.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.
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 +74 -125
  4. package/src/commands/init.ts +87 -136
  5. package/src/devPanel.ts +696 -0
  6. package/src/devServiceManager.ts +229 -0
  7. package/src/dispatcher.ts +22 -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
@@ -0,0 +1,696 @@
1
+ /**
2
+ * devPanel — full-screen Nexus Console TUI (redesigned).
3
+ *
4
+ * Two-pane layout:
5
+ * LEFT tab rail — Services · Logs · Commands · Features · Settings
6
+ * RIGHT content for the active tab
7
+ *
8
+ * Keybindings:
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)
18
+ *
19
+ * Zero dependencies: raw ANSI + readline keypress via src/tui.ts.
20
+ */
21
+ import { existsSync } from 'node:fs';
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
+ import { getCommands, runCommand } from './dispatcher.js';
27
+ import { ServiceManager } from './devServiceManager.js';
28
+ import { FEATURES, detectFeatures, installFeature, readRegistry } from './features.js';
29
+
30
+ export interface DevPanelOptions {
31
+ services: ServiceManager;
32
+ /** Invoked on quit (after the panel exits) — used to kill all services. */
33
+ onQuit?: () => void;
34
+ }
35
+
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;
57
+ }
58
+
59
+ /** Commands that need a real TTY (their own prompts) — suspend the panel. */
60
+ const INTERACTIVE_COMMANDS = new Set(['init', 'add', 'plugin', 'queue:work']);
61
+
62
+ /** Commands that take a positional/flag arg we should collect inline. */
63
+ const ARG_COMMANDS = new Set([
64
+ 'make:route', 'make:controller', 'make:model', 'make:service', 'make:repository',
65
+ 'make:middleware', 'make:validator', 'make:job', 'make:event', 'make:listener',
66
+ 'make:policy', 'make:resource', 'make:request', 'make:mail', 'make:room',
67
+ 'make:subgraph', 'make:seeder', 'make:migration', 'make:provider', 'make:plugin',
68
+ 'db:seed', 'db:migrate', 'db:rollback', 'queue:retry', 'add',
69
+ ]);
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
+
433
+ function commandItems(): CommandItem[] {
434
+ return getCommands().map((c) => ({
435
+ name: c.name,
436
+ desc: c.description ?? '',
437
+ needsArgs: ARG_COMMANDS.has(c.name),
438
+ interactive: INTERACTIVE_COMMANDS.has(c.name),
439
+ }));
440
+ }
441
+
442
+ function groupOf(name: string): string {
443
+ if (name.startsWith('make:')) return 'make:*';
444
+ if (name.startsWith('db:')) return 'data';
445
+ if (name.startsWith('queue:')) return 'queue';
446
+ if (name.startsWith('plugin')) return 'plugins';
447
+ if (name === 'down' || name === 'up') return 'maintenance';
448
+ return 'project';
449
+ }
450
+
451
+ const GROUPS = ['project', 'make:*', 'data', 'queue', 'plugins', 'maintenance'];
452
+
453
+ type PaletteRow = { kind: 'header'; label: string } | { kind: 'cmd'; cmd: CommandItem };
454
+
455
+ function groupCommands(items: CommandItem[]): Map<string, CommandItem[]> {
456
+ const grouped = new Map<string, CommandItem[]>();
457
+ for (const g of GROUPS) grouped.set(g, []);
458
+ for (const item of items) {
459
+ const g = groupOf(item.name);
460
+ if (!grouped.has(g)) grouped.set(g, []);
461
+ grouped.get(g)!.push(item);
462
+ }
463
+ return grouped;
464
+ }
465
+
466
+ function flattenPalette(grouped: Map<string, CommandItem[]>): PaletteRow[] {
467
+ const palette: PaletteRow[] = [];
468
+ for (const g of GROUPS) {
469
+ const list = grouped.get(g) ?? [];
470
+ if (list.length === 0) continue;
471
+ palette.push({ kind: 'header', label: g });
472
+ for (const cmd of list) palette.push({ kind: 'cmd', cmd });
473
+ }
474
+ return palette;
475
+ }
476
+
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;
483
+ }
484
+ return current;
485
+ }
486
+
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}`);
512
+ }
513
+
514
+ if (state.confirmQuit) {
515
+ rows.push('');
516
+ rows.push(...renderQuitCard(state, contentW));
517
+ }
518
+ if (state.argMode) {
519
+ rows.push('');
520
+ rows.push(`${ANSI.cyan}${state.argMode.cmd.name}${ANSI.reset} ${state.argMode.buffer}█`);
521
+ }
522
+ if (state.showHelp) {
523
+ rows.push('');
524
+ rows.push(helpBody());
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
+ }
547
+
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
+ }
566
+ }
567
+ // Join with single spaces; clip to width.
568
+ const bar = parts.join(' ');
569
+ return clipVisible(bar, W);
570
+ }
571
+
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);
580
+ }
581
+ }
582
+
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}`;
589
+ }
590
+ }
591
+
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)}`);
607
+ }
608
+ }
609
+ });
610
+ if (services.services.length === 0) out.push(` ${ANSI.dim}No services discovered.${ANSI.reset}`);
611
+ return out;
612
+ }
613
+
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
+ }
630
+
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))}`);
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)}`);
651
+ }
652
+ return out;
653
+ }
654
+
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)}`);
670
+ }
671
+ return out;
672
+ }
673
+
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
+ ];
680
+ }
681
+
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}`;
685
+ }
686
+
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}`;
692
+ }
693
+
694
+ function clamp(n: number, lo: number, hi: number): number {
695
+ return Math.min(Math.max(n, lo), Math.max(lo, hi));
696
+ }